@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
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
2
+ import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
3
+ import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
1
4
  import { NativeHookLaunches } from "./native-hook-launch.ts";
2
5
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
3
6
  import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
@@ -20,17 +23,17 @@ import { buildReviewBrief } from "./review-brief.ts";
20
23
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
21
24
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
22
25
  import { ActivityRing } from "./activity.ts";
23
- import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
26
+ import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
24
27
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
25
28
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
26
- import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
27
- import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
29
+ import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
30
+ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
28
31
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
29
32
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
30
33
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
31
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
32
- import { isSupportedPeer as registrySupportedPeer, definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
33
- import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
34
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
35
+ import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
36
+ import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
34
37
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
35
38
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
36
39
  import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
@@ -45,16 +48,16 @@ import { childEnv } from "./child-process.ts";
45
48
  import { runCheck } from "./checks.ts";
46
49
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
47
50
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
48
- import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
51
+ import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
49
52
  import { resolveSettledRoles } from "./settled-roles.ts";
50
53
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
51
54
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
52
55
  import { ExecutionBudget } from "./execution-budget.ts";
53
56
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
54
57
  import type { ServerWebSocket } from "bun";
55
- import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
56
- import { CodexPeer } from "../adapters/codex-appserver.ts";
57
- import { PiPeer } from "../adapters/pi.ts";
58
+ import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
59
+ import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
60
+ import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
58
61
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
59
62
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
60
63
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
@@ -64,8 +67,8 @@ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, v
64
67
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
65
68
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
66
69
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
67
- import { LocalPeer } from "../adapters/local-worker.ts";
68
- import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
70
+ import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
71
+ import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
69
72
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
70
73
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
71
74
  import { reviewDiff } from "../adapters/claude-print.ts";
@@ -73,7 +76,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
73
76
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
74
77
  import { Briefs } from "../memory/brief.ts";
75
78
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
76
- import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
79
+ import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
77
80
  import { HUB } from "./envelope.ts";
78
81
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
79
82
  import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
@@ -91,7 +94,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
91
94
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
92
95
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
93
96
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
94
- import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
97
+ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
95
98
  import { Bus } from "./bus.ts";
96
99
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
97
100
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -110,7 +113,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
110
113
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
111
114
  import type { JournalDelivery } from "./delivery-journal.ts";
112
115
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
113
- import { changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
116
+ import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
114
117
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
115
118
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
116
119
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
@@ -120,7 +123,7 @@ import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, ty
120
123
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
121
124
  import type { TerminalOpener } from "./terminal-open.ts";
122
125
  import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
123
- import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type ContextConfig } from "./context-window.ts";
126
+ import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
124
127
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
125
128
  import { detectTooling } from "./tooling.ts";
126
129
  import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
@@ -132,6 +135,8 @@ import { nativeRecall } from "../memory/recall.ts";
132
135
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
133
136
 
134
137
  export interface HubConfig {
138
+ adapter_modules?: readonly AdapterModuleDeclaration[];
139
+ adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
135
140
  /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
136
141
  instances?: InstancesConfig;
137
142
  watchdog_ms: number;
@@ -251,7 +256,8 @@ export const DEFAULT_CONFIG: HubConfig = {
251
256
  export { stateDirFor };
252
257
 
253
258
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
254
- const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.identity.mcp !== "channel-plugin"), HUB, DIGEST]);
259
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_status"]);
260
+ const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
255
261
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
256
262
 
257
263
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
@@ -318,11 +324,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
318
324
  catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
319
325
  }
320
326
 
321
- export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
327
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
322
328
  const captured = layers ?? readInstanceConfigLayers(cwd);
323
- const instanceConfig = readInstanceConfig(cwd, captured);
329
+ const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
324
330
  const instances = instanceConfig.instances;
331
+ const adapterCatalog = instanceConfig.catalog;
325
332
  const ignored: string[] = [...instanceConfig.ignored];
333
+ const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
326
334
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
327
335
  const files = CONFIG_FILES.flatMap((name) => {
328
336
  let file: Record<string, any>; // parsed JSON, checked field by field below
@@ -333,8 +341,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
333
341
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
334
342
  throw error;
335
343
  }
344
+ delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
345
+ delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
336
346
  delete file.instances; // Declaration authority was checked against its exact captured origin above.
337
- peerRosterConfig(file.roster, instances);
347
+ peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
338
348
  catalogAssignments(file.catalog_roles);
339
349
  profileName(file.profile);
340
350
  resolveProfile(file.profile, file.profile_overrides);
@@ -349,8 +359,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
349
359
  for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
350
360
  if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
351
361
  instructionProtection(file.protect_instruction_files);
352
- permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
353
- peerStartConfig(file.peers, instances);
362
+ permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
363
+ peerStartConfig(file.peers, instances, adapterCatalog);
354
364
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
355
365
  terminalTemplate(file.terminal?.open);
356
366
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
@@ -358,7 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
358
368
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
359
369
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
360
370
  if (why) ignored.push(why);
361
- for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
371
+ for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
362
372
  return [file];
363
373
  });
364
374
  if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
@@ -388,7 +398,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
388
398
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
389
399
  const conductorPolicy = loadConductorPolicy(cwd, captured);
390
400
  retired.push(...conductorPolicy.notices ?? []);
391
- const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
401
+ const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
402
+ const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
392
403
  const instancePermissions: Record<string, PermissionMode> = {};
393
404
  const instanceModes: PeerStartConfig = {};
394
405
  for (const [id, declaration] of Object.entries(instances ?? {})) {
@@ -404,17 +415,19 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
404
415
  return {
405
416
  ...DEFAULT_CONFIG,
406
417
  ...file,
418
+ ...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
419
+ ...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
407
420
  ...(instances ? { instances } : {}),
408
421
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
409
422
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
410
423
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
411
- permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
424
+ permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
412
425
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
413
426
  permission_default_sources: appliedPermissionSources,
414
427
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
415
428
  ...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
416
- roster: peerRosterConfig(file.roster, instances),
417
- roles: { ...conductorPolicy.roles, ...extraRoles },
429
+ roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
430
+ roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
418
431
  planner: conductorPolicy.planner,
419
432
  conductor: conductorPolicy.conductor,
420
433
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -432,7 +445,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
432
445
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
433
446
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
434
447
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
435
- peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
448
+ peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
436
449
  terminal: { open: terminalTemplate(file.terminal?.open) },
437
450
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
438
451
  review: { ...DEFAULT_CONFIG.review, ...file.review },
@@ -468,6 +481,8 @@ export interface DaemonOptions {
468
481
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
469
482
  snapshot?: typeof snapshot;
470
483
  snapshotBurstMs?: number;
484
+ /** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
485
+ snapshotClock?: () => number;
471
486
  checkoutBranch?: typeof mainCheckoutBranch;
472
487
  /** Host-only admission transport; never selected by project config or tools. */
473
488
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
@@ -516,135 +531,6 @@ interface Client {
516
531
  }
517
532
  type Sock = ServerWebSocket<Client>;
518
533
 
519
- /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
520
- class WsPeer extends BasePeer {
521
- private generation = crypto.randomUUID();
522
- // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
523
- private claimGeneration = crypto.randomUUID();
524
- get sessionGeneration(): string { return this.claimGeneration; }
525
- terminalOwner(launchId: string): { generation: string } | undefined {
526
- return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
527
- ? { generation: this.claimGeneration } : undefined;
528
- }
529
- private delivered = new Set<string>();
530
- private sock: Sock | undefined;
531
- private claimed: Sock | undefined;
532
- private nativeSession: string | undefined;
533
- private nativeActive = false;
534
- /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
535
- pullOnly = false;
536
- steer: PeerAdapter["steer"] = undefined;
537
- lastInboxAt: number | undefined;
538
- private claimedPullOnly = false;
539
- private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
540
- private settleAttachmentWaiters(attached: boolean): void {
541
- for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
542
- }
543
- /** Bounded native-hook observations wait for this exact claim, never a later socket. */
544
- waitForAttach(generation: string): Promise<boolean> {
545
- if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
546
- return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
547
- }
548
- /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
549
- claim(sock: Sock, pullOnly = false): void {
550
- this.settleAttachmentWaiters(false);
551
- this.claimGeneration = crypto.randomUUID();
552
- this.claimed = sock;
553
- this.claimedPullOnly = pullOnly;
554
- }
555
- /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
556
- get channelHeld(): boolean {
557
- return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
558
- }
559
- /** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
560
- standbyHeld(pullOnly: boolean): boolean {
561
- return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
562
- }
563
- /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
564
- * for the id must not take it from a claimant that is still arriving. */
565
- get claiming(): boolean {
566
- return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
567
- }
568
- attach(sock: Sock): void {
569
- if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
570
- if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
571
- this.sock?.close(4000, "replaced");
572
- this.sock = sock;
573
- this.generation = crypto.randomUUID();
574
- this.delivered.clear();
575
- this.nativeSession = undefined;
576
- this.nativeActive = false;
577
- this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
578
- // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
579
- // Pull-only transports expose no steer capability; the bus keeps their important work queued.
580
- this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
581
- if (this.state !== "busy" || !envs.length || envs.some(env => env.priority !== "important")) throw new Error("channel steering requires important work in the current busy turn");
582
- await this.deliver(envs, deliveryId);
583
- };
584
- this.lastInboxAt = undefined;
585
- this.setState("idle");
586
- this.settleAttachmentWaiters(true);
587
- }
588
- detach(sock: Sock): void {
589
- if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
590
- if (this.sock !== sock) return;
591
- this.sock = undefined;
592
- this.steer = undefined;
593
- this.nativeActive = false;
594
- this.pullOnly = false;
595
- this.setState("offline");
596
- }
597
- detachForRoster(): void {
598
- this.settleAttachmentWaiters(false);
599
- this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
600
- this.sock?.close(4000, "peer is off roster; retry when a person adds it");
601
- this.claimed = undefined;
602
- this.sock = undefined;
603
- this.steer = undefined;
604
- this.nativeActive = false;
605
- this.pullOnly = false;
606
- this.setState("offline");
607
- }
608
- async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
609
- if (!this.sock) throw new Error(`${this.id} is offline`);
610
- if (deliveryId) this.delivered.add(deliveryId);
611
- this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
612
- }
613
- /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
614
- notifyQueued(counts: { queued: number; important: number }): void {
615
- if (!this.sock || !this.pullOnly) return;
616
- this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
617
- }
618
- owns(sock: Sock): boolean { return this.sock === sock; }
619
- nativeStarted(session: string): void {
620
- if (!this.sock) return;
621
- this.nativeSession = session;
622
- this.nativeActive = true;
623
- this.setState("busy");
624
- }
625
- nativeStopped(session: string): boolean {
626
- if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
627
- this.nativeSession = session;
628
- this.nativeActive = false;
629
- this.setState("idle");
630
- return true;
631
- }
632
- protected override onWatchdog(): void {
633
- // A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
634
- if (this.nativeActive) { this.touch(); return; }
635
- super.onWatchdog();
636
- }
637
- ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
638
- async start(): Promise<void> {}
639
- async stop(): Promise<void> {
640
- this.settleAttachmentWaiters(false);
641
- this.sock?.close();
642
- }
643
-
644
- recoveryMetadata(): Record<string, unknown> {
645
- return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
646
- }
647
- }
648
534
 
649
535
  export async function startDaemon(opts: DaemonOptions) {
650
536
  const projectId = opts.projectId ?? randomUUID();
@@ -652,26 +538,39 @@ export async function startDaemon(opts: DaemonOptions) {
652
538
  const startupCleanup: (() => void)[] = [];
653
539
  let ready = false;
654
540
  try {
655
- const loadedConfig = opts.config ?? loadConfig(opts.cwd);
541
+ let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
542
+ try { startupLayers = readInstanceConfigLayers(opts.cwd); }
543
+ catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
544
+ const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
545
+ const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
546
+ const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
547
+ const externalIds = new Set(externalAdapters.map(item => item.kind));
548
+ const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
549
+ const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
550
+ : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
656
551
  reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
657
552
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
658
553
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
659
554
  const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
660
- const declarations = validateInstances(loadedConfig.instances);
555
+ const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
556
+ const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
557
+ const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
661
558
  const extraIds = Object.keys(declarations);
662
559
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
663
- const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
664
- // Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
665
- const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
560
+ const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
561
+ // #624: every Claude peer whose own state files the daemon reads, keyed on the Claude transport, never on channel-plugin
562
+ // (agy is a channel-plugin peer with no Claude state): the builtin, then its declared instances.
563
+ const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
564
+ ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
666
565
  const executionRoots = new Map<string, string>();
667
566
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
668
567
  const reviewerUnavailable = new Map<string, string>();
669
- const roles = { ...loadedConfig.roles };
568
+ const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
670
569
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
671
570
  const startModesFromInstances: PeerStartConfig = {};
672
571
  const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
673
572
  for (const [id, declaration] of Object.entries(declarations)) {
674
- roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
573
+ roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
675
574
  if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
676
575
  if (declaration.permission_mode !== undefined) {
677
576
  permissionModesFromInstances[id] = declaration.permission_mode;
@@ -679,19 +578,19 @@ export async function startDaemon(opts: DaemonOptions) {
679
578
  }
680
579
  }
681
580
  const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
682
- roster: peerRosterConfig(loadedConfig.roster, declarations),
683
- permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
684
- peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
581
+ roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
582
+ permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
583
+ peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
685
584
  ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
686
585
  roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
687
586
  // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
688
- const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
689
- const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
690
- const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
691
- const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
692
- const isSupportedPeer = (peer: unknown): peer is string => registrySupportedPeer(peer, declarations);
587
+ const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
588
+ const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
589
+ const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
590
+ const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
591
+ const isSupportedPeer = (peer: unknown): peer is string => adapterCatalog.isSupportedPeer(peer, declarations);
693
592
  const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
694
- const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
593
+ const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
695
594
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
696
595
  const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
697
596
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
@@ -700,8 +599,18 @@ export async function startDaemon(opts: DaemonOptions) {
700
599
  const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
701
600
  : { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
702
601
  const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
703
- const SETTINGS = projectSettings(declarations);
704
- const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
602
+ const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
603
+ const SETTINGS = projectSettings(declarations, adapterCatalog);
604
+ // #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
605
+ // so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
606
+ const staleRosterKey = (key: unknown): string | undefined => {
607
+ const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
608
+ return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
609
+ };
610
+ const settingDef = (key: unknown): SettingDef | string => {
611
+ const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
612
+ return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
613
+ };
705
614
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
706
615
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
707
616
  let rosterChanging: string | undefined;
@@ -709,7 +618,8 @@ export async function startDaemon(opts: DaemonOptions) {
709
618
  let kimiAdmissionId: string | undefined;
710
619
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
711
620
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
712
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations);
621
+ const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
622
+ const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
713
623
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
714
624
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
715
625
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -720,7 +630,7 @@ export async function startDaemon(opts: DaemonOptions) {
720
630
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
721
631
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
722
632
  let runningRouting = currentRouting(opts.cwd, log);
723
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
633
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
724
634
  const startupSources = structuredClone(acceptedSources.trustLayers);
725
635
  const embeddedConfig = structuredClone(config);
726
636
  let desiredConfig = structuredClone(config);
@@ -889,7 +799,7 @@ export async function startDaemon(opts: DaemonOptions) {
889
799
  sock.data.tail?.();
890
800
  sock.data.watch?.();
891
801
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
892
- if (peer instanceof WsPeer) {
802
+ if (isWsPeer(peer)) {
893
803
  peer.detach(sock);
894
804
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
895
805
  }
@@ -925,7 +835,9 @@ export async function startDaemon(opts: DaemonOptions) {
925
835
  // a record left now would make the next ordinary start look like one.
926
836
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
927
837
  const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
928
- const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
838
+ const autoStartAdapter = ADAPTERS.find(adapter => adapter.start.autoStart !== undefined);
839
+ const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
840
+ const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
929
841
  const startedAt = Date.now();
930
842
  /** What crash recovery did or asks the user to do, for `ahub status`. */
931
843
  const crashReport: string[] = [];
@@ -958,7 +870,8 @@ export async function startDaemon(opts: DaemonOptions) {
958
870
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
959
871
  // together. Cached until the board changes: it is asked per recipient of every message.
960
872
  let turnFreeNow: boolean | undefined;
961
- const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
873
+ const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; commit?: string | null; branches?: string; writeScope?: boolean; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null; writeTasks: { id: number; acceptance: number }[]; taskEffect?: boolean; effectsPending?: number }>();
874
+ const ownerWriteStarts = new Map<number, { owner: string; acceptance: number; at: number; root: string; tree: string }>();
962
875
  const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
963
876
  /**
964
877
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
@@ -1009,7 +922,7 @@ export async function startDaemon(opts: DaemonOptions) {
1009
922
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
1010
923
  const requestedPeerStops = new Set<string>();
1011
924
  const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1012
- onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
925
+ onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1013
926
  startupCleanup.push(() => bus.closeJournal());
1014
927
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
1015
928
  const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
@@ -1035,11 +948,18 @@ export async function startDaemon(opts: DaemonOptions) {
1035
948
  bus.setRecoveryHold(false);
1036
949
  }
1037
950
  const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
1038
- const armRecoveryLease = (delayMs = 10 * 60_000): void => {
951
+ // One log line per failure streak: the 1 s retry itself is unchanged, only its repeated line is not.
952
+ let recoveryLeaseRetryLogged = false;
953
+ const armRecoveryLease = (delayMs = 10 * 60_000, retrying = false): void => {
954
+ if (!retrying) recoveryLeaseRetryLogged = false;
1039
955
  clearTimeout(recoveryLeaseTimer);
1040
956
  recoveryLeaseTimer = setTimeout(() => {
1041
- if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
1042
- try { bus.setRecoveryHold(false); } catch (error) { log("recovery lease release deferred; delivery storage must be restored before abort"); armRecoveryLease(1000); return; }
957
+ if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) { recoveryLeaseRetryLogged = false; return; }
958
+ try { bus.setRecoveryHold(false); } catch {
959
+ if (!recoveryLeaseRetryLogged) { recoveryLeaseRetryLogged = true; log("recovery lease release deferred; delivery storage must be restored before abort (retrying every second)"); }
960
+ armRecoveryLease(1000, true); return;
961
+ }
962
+ recoveryLeaseRetryLogged = false;
1043
963
  log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
1044
964
  recoveryOperationId = undefined;
1045
965
  recoveryPhase = undefined;
@@ -1299,8 +1219,9 @@ export async function startDaemon(opts: DaemonOptions) {
1299
1219
  if (!peer || peer.state === "offline") return undefined;
1300
1220
  let token = checkConnections.get(peer);
1301
1221
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1302
- const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1303
- if (peer instanceof PiPeer && native === undefined) return undefined;
1222
+ const hasGeneration = "sessionGeneration" in peer;
1223
+ const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1224
+ if (hasGeneration && native === undefined) return undefined;
1304
1225
  return `${instanceId}:${token}:${String(native ?? "")}`;
1305
1226
  };
1306
1227
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
@@ -1400,7 +1321,7 @@ export async function startDaemon(opts: DaemonOptions) {
1400
1321
  // Bind both legacy print transports and task-MCP calls to the child's original review request.
1401
1322
  const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1402
1323
  if (!definitionOf(by)?.reviewer) return undefined;
1403
- const attached=bus.peers.get(by), peer=attached instanceof ReviewerPrintPeer?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1324
+ const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1404
1325
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1405
1326
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
1406
1327
  };
@@ -1414,6 +1335,7 @@ export async function startDaemon(opts: DaemonOptions) {
1414
1335
  return [peer, codes];
1415
1336
  })),
1416
1337
  instances: () => declarations,
1338
+ adapterCatalog,
1417
1339
  reviewOnly: reviewOnlyPeer,
1418
1340
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1419
1341
  reviewAuthority: runningReviewAuthority,
@@ -1434,6 +1356,8 @@ export async function startDaemon(opts: DaemonOptions) {
1434
1356
  catalog,
1435
1357
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1436
1358
  projectedReview: task => reviewProjection(task),
1359
+ ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1360
+ ownerAccepted: task => captureOwnerWriteStart(task),
1437
1361
  reviewBrief: task => {
1438
1362
  const root = executionRoot(task.owner ?? "");
1439
1363
  return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
@@ -1491,7 +1415,7 @@ export async function startDaemon(opts: DaemonOptions) {
1491
1415
  sweepHeld: () => stopping || recoveryActive(),
1492
1416
  cancellationHeld: () => stopping || recoveryActive(),
1493
1417
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1494
- offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
1418
+ offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
1495
1419
  stallNotice: stall => { supervision.taskStalled(stall); },
1496
1420
  roles: config.roles,
1497
1421
  turnFree,
@@ -1691,7 +1615,8 @@ export async function startDaemon(opts: DaemonOptions) {
1691
1615
  } catch { /* no transcript yet: no readback */ }
1692
1616
  };
1693
1617
  /** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
1694
- const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
1618
+ const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer: string): string | undefined => {
1619
+ if (definitionOf(peer)?.transport !== "claude-channel") return undefined; // #624: no Claude transcript for another kind
1695
1620
  const reported = claudeSession(peer);
1696
1621
  if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
1697
1622
  if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
@@ -1736,6 +1661,7 @@ export async function startDaemon(opts: DaemonOptions) {
1736
1661
  scopes = new Map();
1737
1662
  progress.prune();
1738
1663
  factsOn(); // a PII task opening or closing switches tracking at once, not at the next boundary (issue #108)
1664
+ if (t.state !== "in_progress" || ownerWriteStarts.get(t.id)?.owner !== t.owner) ownerWriteStarts.delete(t.id);
1739
1665
  if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
1740
1666
  for (const m of tasks.cohorts.of(t.id)?.members.values() ?? []) if (m.task !== t.id) log(`turn-free: task #${t.id} waits on ${stopEvidence(m.owner)}`);
1741
1667
  }
@@ -1759,10 +1685,10 @@ export async function startDaemon(opts: DaemonOptions) {
1759
1685
  const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
1760
1686
  const contextSession = (peer: PeerId): string | undefined => {
1761
1687
  const p = bus.peers.get(peer);
1762
- if (p instanceof WsPeer && p.claiming) return undefined;
1763
- if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1764
- if (p instanceof CodexPeer) return p.thread || undefined;
1765
- if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1688
+ if (isWsPeer(p) && p.claiming) return undefined;
1689
+ if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
1690
+ if (isCodexPeer(p)) return p.thread || undefined;
1691
+ if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
1766
1692
  return undefined;
1767
1693
  };
1768
1694
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
@@ -1773,8 +1699,9 @@ export async function startDaemon(opts: DaemonOptions) {
1773
1699
  const owner = bus.peers.get(peer);
1774
1700
  const state = bus.stateOf(peer);
1775
1701
  const session = contextSession(peer);
1776
- const generation = owner instanceof WsPeer || owner instanceof CodexPeer ? owner.sessionGeneration : undefined;
1777
- if (!owner || (owner instanceof WsPeer && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1702
+ const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
1703
+ const generation = generationFenced ? owner.sessionGeneration : undefined;
1704
+ if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1778
1705
  const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
1779
1706
  if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
1780
1707
  if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
@@ -1786,7 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
1786
1713
  : `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
1787
1714
  const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
1788
1715
  return new Promise((resolve) => {
1789
- const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(owner instanceof WsPeer || owner instanceof CodexPeer) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1716
+ const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1790
1717
  const done = (summary: string | undefined) => {
1791
1718
  clearTimeout(timer);
1792
1719
  checkpointWaits.delete(peer);
@@ -1814,6 +1741,7 @@ export async function startDaemon(opts: DaemonOptions) {
1814
1741
  }
1815
1742
  };
1816
1743
  const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
1744
+ definitionOf,
1817
1745
  pause: (peer) => bus.pause(peer),
1818
1746
  resume: (peer) => {
1819
1747
  if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
@@ -1915,7 +1843,8 @@ export async function startDaemon(opts: DaemonOptions) {
1915
1843
  if (!snapshotBudget) {
1916
1844
  const requested = opts.snapshotBurstMs ?? 250;
1917
1845
  const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
1918
- snapshotBudget = { deadline: performance.now() + ms };
1846
+ const now = opts.snapshotClock;
1847
+ snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
1919
1848
  setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
1920
1849
  }
1921
1850
  return snapshotBudget;
@@ -1926,28 +1855,71 @@ export async function startDaemon(opts: DaemonOptions) {
1926
1855
  snapshotBudgetNoticed = true;
1927
1856
  } else log(`snapshot failed at ${what}: ${(error as Error).message}`);
1928
1857
  };
1929
- const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
1858
+ const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
1930
1859
  if (!capture) return { ms: 0 };
1931
1860
  const t0 = performance.now(), budget = reserveSnapshotBudget();
1932
- let tree: string | undefined, head: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1861
+ let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
1933
1862
  try {
1934
1863
  repo = peer ? repoFor(peer, budget) : factsRepo;
1935
1864
  const before = repo && headState(repo, budget);
1936
1865
  tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
1937
1866
  const after = repo && headState(repo, budget);
1938
- head = after?.tree;
1867
+ head = after?.tree; commit = after?.head;
1939
1868
  if (!before || !after || before.tree !== after.tree) tree = undefined;
1940
1869
  if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
1941
1870
  if (repo && !tree) log(`snapshot failed at ${what}`);
1942
1871
  } catch (error) {
1943
- tree = undefined; head = undefined; reflog = undefined;
1872
+ tree = undefined; head = undefined; commit = undefined; reflog = undefined;
1944
1873
  snapshotFailure(what, error);
1945
1874
  }
1946
- return { tree, head, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1875
+ return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
1947
1876
  };
1948
1877
  function privateSnapshotWindow(): boolean {
1949
1878
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1950
1879
  }
1880
+ function ownerWriteRoot(peer: string): string | undefined {
1881
+ if (declarations[peer]) {
1882
+ const bound = executionOwners.get(peer);
1883
+ if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
1884
+ }
1885
+ return executionRoot(peer);
1886
+ }
1887
+ function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
1888
+ // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
1889
+ // Bind each owner to its real execution worktree before narrowing this unknown scope.
1890
+ // The directory may contain a plain or still-uncommitted checkout, not just registered refs.
1891
+ if (lstatSync(join(opts.cwd, ".worktrees"), { throwIfNoEntry: false })) return false;
1892
+ return soleWorktree(repo, budget);
1893
+ }
1894
+ function captureOwnerWriteStart(task: Task): void {
1895
+ const accepted = tasks.ownerWriteAcceptance(task), prior = ownerWriteStarts.get(task.id);
1896
+ if (prior && prior.owner === task.owner && prior.acceptance === accepted?.index) return;
1897
+ ownerWriteStarts.delete(task.id);
1898
+ if (!accepted || accepted.index !== task.history.length - 1) return;
1899
+ if (!task.owner || !["implement", "bulk_edit"].includes(task.class) || privateSnapshotWindow()) return;
1900
+ const root = ownerWriteRoot(task.owner);
1901
+ if (root === undefined) return;
1902
+ const captured = snap(`task #${task.id} acceptance`, config.snapshots.enabled, task.owner);
1903
+ if (captured.tree) ownerWriteStarts.set(task.id, { owner: task.owner, acceptance: accepted.index, at: accepted.at, root, tree: captured.tree });
1904
+ }
1905
+ function ownerWriteEvidence(task: Task, since: number): boolean | undefined {
1906
+ if (!task.owner || !turnLog || !config.snapshots.enabled || privateSnapshotWindow()) return undefined;
1907
+ const baseline = ownerWriteStarts.get(task.id);
1908
+ const acceptance = tasks.ownerWriteAcceptance(task)?.index;
1909
+ if (!baseline || baseline.owner !== task.owner || baseline.at !== since || baseline.acceptance !== acceptance || baseline.root !== ownerWriteRoot(task.owner)) return undefined;
1910
+ try {
1911
+ if (turnLog.touchesFor([task.id]).some(touch => touch.peer === task.owner && touch.at >= since)) return true;
1912
+ const canonical = baseline.root === opts.cwd;
1913
+ if (canonical && canonicalWriteUnknown(task)) return undefined;
1914
+ const current = snap(`task #${task.id} done evidence`, true, task.owner);
1915
+ if (!current.repo || !current.tree) return undefined;
1916
+ const paths = knownChangedPaths(current.repo, baseline.tree, current.tree, current.budget);
1917
+ if (paths === undefined) return undefined;
1918
+ if (paths.length) return true;
1919
+ if (canonical && canonicalWriteScope(current.repo, current.budget) !== true) return undefined;
1920
+ return false;
1921
+ } catch { return undefined; } // Unknown is never evidence of no writes, including a missing/pruned tree.
1922
+ }
1951
1923
  function reviewProjection(task: Task): string | undefined {
1952
1924
  const owner = task.owner ?? "", root = executionRoot(owner);
1953
1925
  const repo = repoFor(owner), projections = projectionsFor(owner);
@@ -1982,7 +1954,7 @@ export async function startDaemon(opts: DaemonOptions) {
1982
1954
  }
1983
1955
  return n;
1984
1956
  };
1985
- const onKimiTokens = (total: number, session: string, peer = "kimi") => {
1957
+ const onKimiTokens = (total: number, session: string, peer: string) => {
1986
1958
  const n = addTokens(peer, delta(peer, total, session));
1987
1959
  if (!config.budget.kimi_tokens_5h || !n) return;
1988
1960
  const now = Date.now();
@@ -2045,7 +2017,8 @@ export async function startDaemon(opts: DaemonOptions) {
2045
2017
  if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
2046
2018
  claudeContextSeen.set(claudePeer, payload);
2047
2019
  const bound = contextSession(claudePeer);
2048
- if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
2020
+ const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
2021
+ if (reading) contexts.report(claudePeer, reading, bound);
2049
2022
  } catch { /* absent or unreadable native telemetry is unknown */ }
2050
2023
  }, 1000),
2051
2024
  setInterval(() => budget.tick(), 30_000),
@@ -2065,7 +2038,10 @@ export async function startDaemon(opts: DaemonOptions) {
2065
2038
  claudeUsageSeen = mtime;
2066
2039
  const usage = JSON.parse(readFileSync(file, "utf8"));
2067
2040
  // The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
2068
- budget.report("claude", claudeWindows(usage.rate_limits), false, Number(usage.at) || mtime);
2041
+ for (const adapter of ADAPTERS.filter(adapter => adapter.transport === "claude-channel")) {
2042
+ const windows = readNativeQuota(adapter, usage.rate_limits);
2043
+ if (windows) budget.report(adapter.kind, windows, false, Number(usage.at) || mtime);
2044
+ }
2069
2045
  } catch {
2070
2046
  // no tee in this session, or a half-written file: next round
2071
2047
  }
@@ -2112,6 +2088,10 @@ export async function startDaemon(opts: DaemonOptions) {
2112
2088
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
2113
2089
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
2114
2090
  const [by, op, input] = args;
2091
+ if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
2092
+ const nativeTurn = turns.get(by);
2093
+ const effectful = ownerToolEffect(op, input);
2094
+ if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
2115
2095
  const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
2116
2096
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2117
2097
  taskOpsInFlight++;
@@ -2119,6 +2099,7 @@ export async function startDaemon(opts: DaemonOptions) {
2119
2099
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2120
2100
  const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
2121
2101
  const text = await taskOpBody(...args);
2102
+ if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2122
2103
  const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2123
2104
  if (conducted) {
2124
2105
  const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
@@ -2128,16 +2109,18 @@ export async function startDaemon(opts: DaemonOptions) {
2128
2109
  const hint = args[3] ? ownHeldHint(by) : "";
2129
2110
  return hint ? `${text}\n\n${hint}` : text;
2130
2111
  } catch (error) {
2112
+ if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2131
2113
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2132
2114
  const hint = args[3] ? ownHeldHint(by) : "";
2133
2115
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
2134
2116
  throw error;
2135
2117
  } finally {
2118
+ if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
2136
2119
  taskOpsInFlight--;
2137
2120
  }
2138
2121
  };
2139
2122
  function ownHeldHint(peer: PeerId): string {
2140
- try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
2123
+ try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
2141
2124
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
2142
2125
  }
2143
2126
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
@@ -2271,7 +2254,7 @@ export async function startDaemon(opts: DaemonOptions) {
2271
2254
  switch (op) {
2272
2255
  case "hub_delivery_retry": {
2273
2256
  if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
2274
- if (bus.peers.get(by) instanceof LocalPeer || bus.peers.get(by) instanceof AcpPeer) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2257
+ if (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
2275
2258
  if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
2276
2259
  bus.retryDelivery(by, a.delivery_id);
2277
2260
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
@@ -2355,7 +2338,7 @@ export async function startDaemon(opts: DaemonOptions) {
2355
2338
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2356
2339
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2357
2340
  case "hub_review":
2358
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2341
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2359
2342
  case "hub_remember":
2360
2343
  return tasks.remember(by, a);
2361
2344
  case "hub_checkpoint": {
@@ -2460,12 +2443,12 @@ export async function startDaemon(opts: DaemonOptions) {
2460
2443
  if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2461
2444
  if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
2462
2445
  const codex = bus.peers.get(turn.peer);
2463
- if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
2446
+ if (!isCodexPeer(codex)) throw new Error("codex is not attached");
2464
2447
  const workspace = turnLog!.workspace(turn.id);
2465
2448
  if (workspace !== undefined) {
2466
- const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
2449
+ const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
2467
2450
  if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
2468
- const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
2451
+ const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
2469
2452
  const bound = executionOwners.get(turn.peer);
2470
2453
  if (validated !== workspace || executionRoot(turn.peer) !== workspace || bound?.owner !== codex || bound.cwd !== workspace) throw new Error("turn workspace differs from the current native owner; inspect before reverting");
2471
2454
  }
@@ -2550,7 +2533,7 @@ export async function startDaemon(opts: DaemonOptions) {
2550
2533
  * waited past `budget.stale_min` the status says so, and the person decides. Read-only.
2551
2534
  */
2552
2535
  const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
2553
- if (!(peer instanceof AcpPeer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
2536
+ if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
2554
2537
  const since = bus.oldestQueuedImportantAt(id);
2555
2538
  if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
2556
2539
  const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
@@ -2563,7 +2546,7 @@ export async function startDaemon(opts: DaemonOptions) {
2563
2546
  };
2564
2547
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
2565
2548
  const recoveryReady = () => {
2566
- if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
2549
+ if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || piCallsInFlight() !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
2567
2550
  if (checkPending.size !== 0) return false;
2568
2551
  if (!recoveryPeerSnapshot) return true;
2569
2552
  const current = recoveryPeers();
@@ -2593,7 +2576,8 @@ export async function startDaemon(opts: DaemonOptions) {
2593
2576
  });
2594
2577
  };
2595
2578
  /** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
2596
- const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2579
+ const claudeSession = (peer: string): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
2580
+ if (definitionOf(peer)?.transport !== "claude-channel") return {}; // #624: Claude state files belong to Claude peers only
2597
2581
  try {
2598
2582
  const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
2599
2583
  if (value.instanceId !== instanceId) return {};
@@ -2624,7 +2608,7 @@ export async function startDaemon(opts: DaemonOptions) {
2624
2608
  const claudeVersions = new Map<string, string>();
2625
2609
  const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
2626
2610
  const claudeVersion = (): string | undefined => {
2627
- const path = claudeSession().transcriptPath;
2611
+ const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
2628
2612
  if (!path) return undefined;
2629
2613
  let fd: number | undefined;
2630
2614
  try {
@@ -2728,17 +2712,19 @@ export async function startDaemon(opts: DaemonOptions) {
2728
2712
  log(`WARNING permission start ${peer}: ${mode} from ${permissionModeSources.get(peer) ?? "human runtime command"}; effective ${effective}${effective === "unmanaged" ? "; native mode is unmanaged; requested mode not applied" : pending ? "; waits for a person's y in ahub console" : "; matching actions run without asking"}`);
2729
2713
  };
2730
2714
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
2731
- if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2732
- if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
2733
- if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
2715
+ if (readOnlyPeer(id)) return "read-only";
2716
+ if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
2734
2717
  const launch = claudePermissionLaunch(id);
2735
2718
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2736
2719
  }
2737
- return permissionMode(id);
2720
+ const native = peer?.permissionModeState;
2721
+ // An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
2722
+ if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
2723
+ return native ?? permissionMode(id);
2738
2724
  };
2739
2725
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
2740
2726
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
2741
- if (note && peer instanceof WsPeer && peer.pullOnly) return { permissionNote: note };
2727
+ if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
2742
2728
  const mode = permissionDisplay(id, peer);
2743
2729
  return mode === "ask" ? {} : { permissionMode: mode };
2744
2730
  };
@@ -2752,6 +2738,7 @@ export async function startDaemon(opts: DaemonOptions) {
2752
2738
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
2753
2739
  const reservations = board.statusReservations();
2754
2740
  const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
2741
+ for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
2755
2742
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
2756
2743
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
2757
2744
  return [...ids].filter(peer => membershipAllowed(peer) || reservations.includes(peer) || bus.queued(peer) > 0 || (() => { const summary = bus.queueSummary(peer); return summary.needsReview > 0 || !!summary.liveAccepted?.length; })() || open.some(task => task.owner === peer || task.reviewer === peer));
@@ -2778,7 +2765,7 @@ export async function startDaemon(opts: DaemonOptions) {
2778
2765
  pendingRestart,
2779
2766
  retainedConfig,
2780
2767
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2781
- startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2768
+ startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
2782
2769
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2783
2770
  lifecycleStages: [...lifecycleConfig.stages],
2784
2771
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
@@ -2787,6 +2774,11 @@ export async function startDaemon(opts: DaemonOptions) {
2787
2774
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
2788
2775
  protocol: PROTOCOL,
2789
2776
  features: DAEMON_FEATURES,
2777
+ configuredAdapters: configuredIds,
2778
+ ...(externalAdapters.length ? { externalAdapters } : {}),
2779
+ ...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
2780
+ startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
2781
+ readOnlyPeers: extraIds.filter(readOnlyPeer),
2790
2782
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2791
2783
  activeInstances: extraIds,
2792
2784
  stopping,
@@ -2796,7 +2788,7 @@ export async function startDaemon(opts: DaemonOptions) {
2796
2788
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2797
2789
  codexProxyPort: opts.codexProxyPort,
2798
2790
  peers: Object.fromEntries(
2799
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof WsPeer && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2791
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2800
2792
  ),
2801
2793
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2802
2794
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -3042,8 +3034,8 @@ export async function startDaemon(opts: DaemonOptions) {
3042
3034
  let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
3043
3035
  function settlePiContinuity(): void {
3044
3036
  if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
3045
- const peer = bus.peers.get("pi");
3046
- if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
3037
+ const peer = bus.peers.get(continuityKind);
3038
+ if (!isPiPeer(peer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
3047
3039
  const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
3048
3040
  if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
3049
3041
  const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
@@ -3072,8 +3064,8 @@ export async function startDaemon(opts: DaemonOptions) {
3072
3064
  }
3073
3065
  const recordSessions = () => {
3074
3066
  if (stopping) return;
3075
- const pi = bus.peers.get("pi");
3076
- if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3067
+ const pi = bus.peers.get(continuityKind);
3068
+ if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3077
3069
  piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
3078
3070
  }
3079
3071
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
@@ -3095,19 +3087,19 @@ export async function startDaemon(opts: DaemonOptions) {
3095
3087
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
3096
3088
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
3097
3089
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
3098
- for (const step of crashPlan(prev.peers, declarations)) {
3090
+ for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
3099
3091
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
3100
3092
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
3101
3093
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
3102
3094
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
3103
3095
  if (step.resume) {
3104
- const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
3096
+ const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
3105
3097
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
3106
3098
  report(`pi not resumed (${String(r.error)}); ${step.how}`);
3107
3099
  } else report(step.how);
3108
3100
  // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
3109
- if (step.tui && startMode("pi") === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
3110
- const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
3101
+ if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
3102
+ const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
3111
3103
  const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
3112
3104
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
3113
3105
  continue;
@@ -3161,13 +3153,20 @@ export async function startDaemon(opts: DaemonOptions) {
3161
3153
  const capture = config.snapshots.enabled && !!turnLog;
3162
3154
  const start = capture && !pii ? snap(`the start of ${id}`, config.snapshots.enabled, e.peer) : undefined; // before the peer is handed anything: the tap runs inside setState
3163
3155
  try { if (capture) turnLog?.begin(id, e.peer, start?.tree, declarations[e.peer] ? executionRoot(e.peer) : undefined); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
3156
+ const writeTasks = tasks.ownerTurnTasks(e.peer);
3157
+ let branches: string | undefined, writeScope: boolean | undefined;
3158
+ if (writeTasks.length && start?.repo) try {
3159
+ branches = branchHeads(start.repo, start.budget);
3160
+ writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
3161
+ } catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
3164
3162
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3165
- turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3163
+ turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3166
3164
  event({ type: "turn_start", peer: e.peer, turn: id });
3167
3165
  } else if (!busy && open) {
3168
3166
  turns.delete(e.peer);
3169
3167
  let afterTurn: (() => void) | undefined;
3170
3168
  let files: number | undefined;
3169
+ let writeProgress: boolean | undefined;
3171
3170
  let snapshotMs = open.snapshotMs;
3172
3171
  if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
3173
3172
  try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
@@ -3189,7 +3188,7 @@ export async function startDaemon(opts: DaemonOptions) {
3189
3188
  if (!end.head) throw new Error("end HEAD evidence is unavailable");
3190
3189
  const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
3191
3190
  if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
3192
- const checkBudget = () => { if (end.budget && performance.now() >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3191
+ const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3193
3192
  const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
3194
3193
  for (const path of dirtyPaths) {
3195
3194
  checkBudget();
@@ -3216,6 +3215,21 @@ export async function startDaemon(opts: DaemonOptions) {
3216
3215
  }
3217
3216
  }
3218
3217
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
3218
+ if (open.writeTasks.length) {
3219
+ if ((files ?? 0) > 0 || open.commit !== undefined && end.commit !== undefined && open.commit !== end.commit) writeProgress = true;
3220
+ else if (open.commit !== undefined && end.commit !== undefined && open.branches !== undefined && end.repo) {
3221
+ try {
3222
+ const branches = branchHeads(end.repo, end.budget);
3223
+ if (branches !== undefined) {
3224
+ if (branches !== open.branches) writeProgress = true;
3225
+ else if (files === 0 && open.writeScope === true) {
3226
+ const scope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(end.repo, end.budget) : true;
3227
+ if (scope === true) writeProgress = false;
3228
+ }
3229
+ }
3230
+ } catch (error) { snapshotFailure(`the branch refs of ${open.id}`, error); }
3231
+ }
3232
+ }
3219
3233
  snapshotMs = (snapshotMs ?? 0) + end.ms;
3220
3234
  try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
3221
3235
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
@@ -3229,6 +3243,12 @@ export async function startDaemon(opts: DaemonOptions) {
3229
3243
  tasks.cohorts.turnEnded(e.peer);
3230
3244
  }
3231
3245
  try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
3246
+ if (definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) {
3247
+ const root = ownerWriteRoot(e.peer);
3248
+ const elsewhere = root === opts.cwd && open.writeTasks.some(binding => { const task = board.get(binding.id); return task && canonicalWriteUnknown(task); });
3249
+ const changed = e.state === "idle" && !open.private && root !== undefined && !(writeProgress === false && elsewhere) ? writeProgress : undefined;
3250
+ try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
3251
+ }
3232
3252
  }
3233
3253
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3234
3254
  else offlineSince.delete(e.peer);
@@ -3376,7 +3396,7 @@ export async function startDaemon(opts: DaemonOptions) {
3376
3396
  const replacingPi = new Set<PiPeer>();
3377
3397
  const piReplacementAuthority = Symbol("console Pi replacement");
3378
3398
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
3379
- const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
3399
+ const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
3380
3400
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
3381
3401
  (error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
3382
3402
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
@@ -3417,7 +3437,7 @@ export async function startDaemon(opts: DaemonOptions) {
3417
3437
  const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
3418
3438
  const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
3419
3439
  /** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
3420
- const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
3440
+ const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
3421
3441
  const startWords = (peer: string, mode: StartMode): string[] => {
3422
3442
  const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
3423
3443
  return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
@@ -3426,6 +3446,29 @@ export async function startDaemon(opts: DaemonOptions) {
3426
3446
  // ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
3427
3447
  // exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
3428
3448
  const opening = new Map<string, number>();
3449
+ // #286 AC10: a peer with no native TUI (headless Kimi, Qwen) is watched through `ahub watch <peer>`. A start the hub made
3450
+ // itself that really started such a peer opens that view through the same provider, with fixed words and no caller text.
3451
+ // The headless start has already succeeded: a missing or failing provider never fails it, it only reports the command.
3452
+ const watchOpening = new Map<string, number>();
3453
+ const watchSentence = (watch: unknown): string => {
3454
+ const w = watch as { command?: string; opened?: string } | undefined;
3455
+ return w?.command ? (w.opened ? ` A watch pane is open (${w.command}).` : ` Watch it with ${w.command}.`) : "";
3456
+ };
3457
+ async function openWatchPane(peer: string, result: Record<string, unknown>): Promise<Record<string, unknown>> {
3458
+ if (result.ok !== true || result.already || declarations[peer] || !definitionOf(peer)?.capabilities?.watch) return result;
3459
+ const command = `ahub watch ${peer}`;
3460
+ if (Date.now() - (watchOpening.get(peer) ?? 0) < PEER_START_RETRY_MS) return { ...result, watch: { command } };
3461
+ watchOpening.set(peer, Date.now());
3462
+ const opened = await opener.open(`${peer} watch (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, "watch", peer]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
3463
+ if (!opened.ok) {
3464
+ watchOpening.delete(peer);
3465
+ if (opened.detail) log(`terminal provider for ${peer} watch: ${opened.detail}`);
3466
+ try { notify(`${peer}: started headless; the hub opened no watch pane (${opened.why}). Watch it with ${command}`); } catch { /* the start stands */ }
3467
+ return { ...result, watch: { command } };
3468
+ }
3469
+ try { notify(`${peer}: started headless; the hub opened a watch pane running ${command} (${opened.via})`); } catch { /* the pane is open either way */ }
3470
+ return { ...result, watch: { command, opened: opened.via } };
3471
+ }
3429
3472
  /** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
3430
3473
  // ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
3431
3474
  const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
@@ -3473,8 +3516,8 @@ export async function startDaemon(opts: DaemonOptions) {
3473
3516
  // ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
3474
3517
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
3475
3518
  // issued to tell the two apart.
3476
- if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
3477
- return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
3519
+ if (isPiPeer(owner) && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
3520
+ return openWatchPane(peer, await startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {}));
3478
3521
  }
3479
3522
  // #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
3480
3523
  // dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
@@ -3507,11 +3550,11 @@ export async function startDaemon(opts: DaemonOptions) {
3507
3550
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
3508
3551
  if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
3509
3552
  const owner = bus.peers.get(peer);
3510
- const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3553
+ const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3511
3554
  if (stopNotice) return { ok: false, error: stopNotice };
3512
- if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3513
- if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3514
- if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3555
+ if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3556
+ if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3557
+ if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3515
3558
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
3516
3559
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
3517
3560
  try {
@@ -3519,7 +3562,7 @@ export async function startDaemon(opts: DaemonOptions) {
3519
3562
  if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
3520
3563
  const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
3521
3564
  // Pi persists a session only after its first message: take live empty-session proof before stopping.
3522
- if (owner instanceof PiPeer && owner.state === "idle") {
3565
+ if (isPiPeer(owner) && owner.state === "idle") {
3523
3566
  try { await owner.captureResume(); }
3524
3567
  catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
3525
3568
  }
@@ -3559,7 +3602,7 @@ export async function startDaemon(opts: DaemonOptions) {
3559
3602
  try {
3560
3603
  if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
3561
3604
  let executionCwd: string;
3562
- try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
3605
+ try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
3563
3606
  catch (error) { return { ok: false, error: (error as Error).message }; }
3564
3607
  executionRoots.set(peer, executionCwd);
3565
3608
  applyPendingPermissionDefault(peer);
@@ -3573,6 +3616,13 @@ export async function startDaemon(opts: DaemonOptions) {
3573
3616
 
3574
3617
  async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
3575
3618
  const adapter = definitionOf(peer);
3619
+ if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
3620
+ && (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
3621
+ return { ok: false, error: "configured ACP session selector is invalid" };
3622
+ }
3623
+ const retainedOwner = bus.peers.get(peer);
3624
+ if (adapter?.permission.genericAcp && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
3625
+ if (adapter?.permission.genericAcp && (args.model || args.route || args.backend || args.unattended || args.providerProfile || args.sessionFile || args.fresh)) return { ok: false, error: "configured ACP accepts no native launch overrides" };
3576
3626
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
3577
3627
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3578
3628
  if (preflight) return { ok: false, error: preflight };
@@ -3581,7 +3631,7 @@ export async function startDaemon(opts: DaemonOptions) {
3581
3631
  const owner = bus.peers.get(peer);
3582
3632
  if ((args.mode ?? startMode(peer)) === "tui") {
3583
3633
  if (args.mode !== "tui") return { ok: false, error: "Kimi uses its own TUI (pull-only); run ahub kimi in a terminal, or ahub kimi --mode headless", command: "ahub kimi" };
3584
- if (owner instanceof AcpPeer && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
3634
+ if (isAcpPeer(owner) && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
3585
3635
  clearKimiAdmission();
3586
3636
  kimiAdmissionId = crypto.randomUUID();
3587
3637
  kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
@@ -3589,7 +3639,7 @@ export async function startDaemon(opts: DaemonOptions) {
3589
3639
  kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
3590
3640
  return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
3591
3641
  }
3592
- if (owner instanceof WsPeer && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
3642
+ if (isWsPeer(owner) && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
3593
3643
  if (hasKimiHubMcp(opts.cwd)) return { ok: false, error: "Kimi's project MCP file still has agent-hub; a person must run ahub kimi --mode headless to remove it before ACP starts", command: "ahub kimi --mode headless" };
3594
3644
  clearKimiAdmission();
3595
3645
  }
@@ -3604,7 +3654,7 @@ export async function startDaemon(opts: DaemonOptions) {
3604
3654
  if (args.replaceSession) {
3605
3655
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3606
3656
  if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
3607
- if (!(existing instanceof PiPeer) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
3657
+ if (!isPiPeer(existing) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
3608
3658
  const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
3609
3659
  const previous = existing.recoveryMetadata();
3610
3660
  recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
@@ -3612,7 +3662,7 @@ export async function startDaemon(opts: DaemonOptions) {
3612
3662
  mute(existing);
3613
3663
  await stopPiForReplacement(existing);
3614
3664
  }
3615
- if (existing instanceof PiPeer && !args.replaceSession) {
3665
+ if (isPiPeer(existing) && !args.replaceSession) {
3616
3666
  let saved = existing.recoveryMetadata();
3617
3667
  const launch = saved.launch as Record<string, unknown>;
3618
3668
  if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
@@ -3645,10 +3695,13 @@ export async function startDaemon(opts: DaemonOptions) {
3645
3695
  if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
3646
3696
  if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
3647
3697
  } else if (existing && existing.state !== "offline") {
3648
- return { ok: true, already: true, ...(existing instanceof CodexPeer ? { proxyUrl: existing.proxyUrl } : {}) };
3698
+ if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
3699
+ return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
3700
+ }
3701
+ return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
3649
3702
  }
3650
3703
  if (!adapter?.start.reconfigureWhileAttached) {
3651
- if (existing instanceof PiPeer) await stopPiForReplacement(existing);
3704
+ if (isPiPeer(existing)) await stopPiForReplacement(existing);
3652
3705
  else await existing?.stop();
3653
3706
  }
3654
3707
  if (adapter?.start.daemonPort === "owned-acp") {
@@ -3664,7 +3717,7 @@ export async function startDaemon(opts: DaemonOptions) {
3664
3717
  if (adapter?.start.acpCommand) {
3665
3718
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
3666
3719
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
3667
- onActivity: item => activityRings[peer]!.append(item),
3720
+ onActivity: item => activityRings[peer]?.append(item),
3668
3721
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3669
3722
  cmd, kind: adapter.kind,
3670
3723
  permissionMode: permissionMode(peer),
@@ -3693,11 +3746,9 @@ export async function startDaemon(opts: DaemonOptions) {
3693
3746
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3694
3747
  const current = contextSession(peer);
3695
3748
  if (!current) return;
3696
- contexts.report(peer,
3697
- observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3698
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3699
- : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
3700
- current);
3749
+ const reading = readNativeContext(adapter,
3750
+ { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
3751
+ if (reading) contexts.report(peer, reading, current);
3701
3752
  },
3702
3753
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3703
3754
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
@@ -3711,36 +3762,39 @@ export async function startDaemon(opts: DaemonOptions) {
3711
3762
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
3712
3763
  return { ok: true };
3713
3764
  }
3714
- if (adapter?.transport === "acp" && adapter.start.create) {
3715
- // ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
3765
+ if (adapter?.transport === "acp" && adapter.start.create && adapter.start.launch) {
3766
+ // Configured ACP definitions own immutable argv and policy; builtins retain their verified launch port.
3716
3767
  const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
3717
- userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
3768
+ userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
3718
3769
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3719
- const permission = permissionMode(peer);
3770
+ const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
3720
3771
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3721
- onActivity: item => activityRings[peer]!.append(item),
3772
+ onActivity: item => activityRings[peer]?.append(item),
3722
3773
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3723
- kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3774
+ kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3775
+ ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3724
3776
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3725
3777
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3726
- autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3727
- log: () => log("Qwen runtime diagnostic: details withheld"),
3778
+ autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
3779
+ log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
3728
3780
  onUsageDiagnostic: (observation, sessionId) => {
3729
3781
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3730
3782
  const current = contextSession(peer); if (!current) return;
3731
- contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3732
- ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3733
- : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
3783
+ const reading = readNativeContext(adapter,
3784
+ { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
3785
+ if (reading) contexts.report(peer, reading, current);
3734
3786
  },
3735
3787
  onTurnFailure: () => { supervisionTurns.delete(peer); },
3736
3788
  mcpServers,
3737
3789
  preamble: sessionRoleContract(peer),
3738
3790
  } } });
3739
- if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
3791
+ if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
3740
3792
  recoveryTaskPreface(peer); await ensurePreface(peer);
3741
3793
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
3742
3794
  catch (error) {
3743
- await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3795
+ await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
3796
+ if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
3797
+ if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
3744
3798
  if (error instanceof QwenIdentityError) throw error;
3745
3799
  throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
3746
3800
  }
@@ -3753,7 +3807,8 @@ export async function startDaemon(opts: DaemonOptions) {
3753
3807
  admitAttach: () => membershipAllowed(peer),
3754
3808
  permissionMode: permissionMode(peer),
3755
3809
  onTokens: (added) => void addTokens(peer, added),
3756
- onContext: (reading) => contexts.report(peer, reading, contextSession(peer)),
3810
+ contextReader: adapter.budget?.context,
3811
+ onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
3757
3812
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
3758
3813
  onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
3759
3814
  onTurn: (native) => {
@@ -3802,7 +3857,7 @@ export async function startDaemon(opts: DaemonOptions) {
3802
3857
  ...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
3803
3858
  ].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
3804
3859
  preamble: sessionRoleContract(peer),
3805
- onUsage: (rateLimits, hard) => budget.report(peer, codexWindows(rateLimits), hard),
3860
+ onUsage: (rateLimits, hard) => { const windows = readNativeQuota(adapter, rateLimits); if (windows) budget.report(peer, windows, hard); },
3806
3861
  usagePollMs: config.budget.poll_min * 60_000,
3807
3862
  cwd: executionCwd,
3808
3863
  watchdogMs: config.watchdog_ms,
@@ -3828,7 +3883,7 @@ export async function startDaemon(opts: DaemonOptions) {
3828
3883
  staySwitch: () => activeRouting(),
3829
3884
  onRoute: record => event({ type: "route", peer, ...record }),
3830
3885
  efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3831
- underBudget: () => { const current = bus.peers.get(peer); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3886
+ underBudget: () => { const current = bus.peers.get(peer); return isPiPeer(current) && tasks.budgetApplies(current.budgetEnvelopes, peer); },
3832
3887
  onCooldown: record => {
3833
3888
  log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
3834
3889
  event({ type: "cooldown", peer, ...record });
@@ -4112,9 +4167,9 @@ export async function startDaemon(opts: DaemonOptions) {
4112
4167
  return { ok: false, error: `unknown peer "${peer}"` };
4113
4168
  }
4114
4169
 
4115
- async function admitPiRequest(peer = "pi"): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
4170
+ async function admitPiRequest(peer: string): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
4116
4171
  const current = bus.peers.get(peer);
4117
- if (!(current instanceof PiPeer)) return { allowed: false, reason: "Pi owner is not active" };
4172
+ if (!isPiPeer(current)) return { allowed: false, reason: "Pi owner is not active" };
4118
4173
  const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
4119
4174
  const denied = decisions.find(d => !d.allowed);
4120
4175
  const stop = denied ? current.recordBudgetStop(denied) : undefined;
@@ -4147,7 +4202,7 @@ export async function startDaemon(opts: DaemonOptions) {
4147
4202
  } else {
4148
4203
  if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
4149
4204
  const peer = bus.peers.get(id);
4150
- if (peer instanceof LocalPeer) {
4205
+ if (isLocalPeer(peer)) {
4151
4206
  const generation = pauseGeneration.get(id);
4152
4207
  const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
4153
4208
  try { await validateLocalChoice(launch.route, launch.model); }
@@ -4168,6 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
4168
4223
 
4169
4224
  const conductor = new Conductor(conductorHolds, {
4170
4225
  roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
4226
+ adapterCatalog,
4171
4227
  status: () => {
4172
4228
  const quota = budget.status();
4173
4229
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
@@ -4200,7 +4256,7 @@ export async function startDaemon(opts: DaemonOptions) {
4200
4256
  pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
4201
4257
  validateRelease: async peer => {
4202
4258
  const owner = bus.peers.get(peer);
4203
- if (!(owner instanceof LocalPeer)) return;
4259
+ if (!isLocalPeer(owner)) return;
4204
4260
  const generation = pauseGeneration.get(peer);
4205
4261
  const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
4206
4262
  await validateLocalChoice(launch.route, launch.model);
@@ -4375,12 +4431,12 @@ export async function startDaemon(opts: DaemonOptions) {
4375
4431
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4376
4432
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4377
4433
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4378
- const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations));
4434
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
4379
4435
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4380
4436
  }
4381
4437
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
4382
- const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
4383
- validateEffectiveConfig(fileConfig);
4438
+ const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
4439
+ validateEffectiveConfig(fileConfig, adapterCatalog);
4384
4440
  const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
4385
4441
  const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
4386
4442
  const previews: { key: string; before: string; after: string; effect?: string }[] = [];
@@ -4389,7 +4445,7 @@ export async function startDaemon(opts: DaemonOptions) {
4389
4445
  const prefix = layer.name.endsWith(".toml") ? "routing" : "";
4390
4446
  for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
4391
4447
  }
4392
- if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
4448
+ if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)])) paths.add(change.key);
4393
4449
  for (const key of paths) {
4394
4450
  if (scope && key !== scope) continue;
4395
4451
  const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
@@ -4404,10 +4460,12 @@ export async function startDaemon(opts: DaemonOptions) {
4404
4460
  const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
4405
4461
  const rawAfter = current ? valueAt(current.raw, path) : undefined;
4406
4462
  if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
4463
+ // Compare the running declaration, never a pending desired value accepted by an earlier reload.
4407
4464
  // Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
4408
4465
  // the live catalog while the old identities, paths and account callbacks still own the daemon.
4409
4466
  const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
4410
- if (!route && boundId && JSON.stringify(desiredConfig.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId])) {
4467
+ const boundKind = boundId ? declarations[boundId]?.kind ?? boundId : undefined;
4468
+ if (!route && boundId && (JSON.stringify(config.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId]) || JSON.stringify(boundKind ? config.adapters?.[boundKind] : undefined) !== JSON.stringify(boundKind ? fileConfig.adapters?.[boundKind] : undefined))) {
4411
4469
  rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
4412
4470
  continue;
4413
4471
  }
@@ -4471,11 +4529,11 @@ export async function startDaemon(opts: DaemonOptions) {
4471
4529
  }
4472
4530
  candidate.retired = fileConfig.retired;
4473
4531
  routing.retired = sources.routing.retired;
4474
- const changes = configChanges({ ...config, roles: policyRoles }, candidate);
4532
+ const changes = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]);
4475
4533
  const running = structuredClone({ ...config, roles: policyRoles });
4476
4534
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4477
4535
  running.retired = fileConfig.retired;
4478
- validateEffectiveConfig(running);
4536
+ validateEffectiveConfig(running, adapterCatalog);
4479
4537
  conductorPeer(running.roles);
4480
4538
  for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
4481
4539
  const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
@@ -4505,7 +4563,7 @@ export async function startDaemon(opts: DaemonOptions) {
4505
4563
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
4506
4564
  texts["config.json"] = next["config.json"];
4507
4565
  texts["config.local.json"] = next["config.local.json"];
4508
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4566
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4509
4567
  const refusal = prepared.rejected.find(item => item.key === scope);
4510
4568
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4511
4569
  return prepared.fileConfig;
@@ -4513,7 +4571,7 @@ export async function startDaemon(opts: DaemonOptions) {
4513
4571
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
4514
4572
  const texts = configSourceTexts(opts.cwd);
4515
4573
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
4516
- const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
4574
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4517
4575
  const refusal = prepared.rejected.find(item => item.key === scope);
4518
4576
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4519
4577
  }
@@ -4530,7 +4588,7 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4588
  const texts = configSourceTexts(opts.cwd);
4531
4589
  fingerprint = configFingerprint(texts);
4532
4590
  if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
4533
- const sources = parseConfigSources(texts);
4591
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
4534
4592
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
4535
4593
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
4536
4594
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -4564,7 +4622,7 @@ export async function startDaemon(opts: DaemonOptions) {
4564
4622
  desiredRouting = structuredClone(prepared.routing);
4565
4623
  acknowledgeConfiguration(sources, prepared.accepted);
4566
4624
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4567
- pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4625
+ pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4568
4626
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4569
4627
  retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
4570
4628
  storedConfigCache = undefined;
@@ -4597,7 +4655,7 @@ export async function startDaemon(opts: DaemonOptions) {
4597
4655
  const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
4598
4656
  if (storedConfigCache?.stamp !== stamp) {
4599
4657
  let loaded: HubConfig | undefined;
4600
- try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
4658
+ try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
4601
4659
  storedConfigCache = { stamp, config: loaded };
4602
4660
  }
4603
4661
  return storedConfigCache.config;
@@ -4618,9 +4676,9 @@ export async function startDaemon(opts: DaemonOptions) {
4618
4676
  let routingProblem: string | undefined;
4619
4677
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
4620
4678
  const rows = settingRows({
4621
- instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4679
+ instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4622
4680
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4623
- next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4681
+ next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4624
4682
  roles: roleState,
4625
4683
  mode: (peer) => {
4626
4684
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4659,24 +4717,26 @@ export async function startDaemon(opts: DaemonOptions) {
4659
4717
  if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
4660
4718
  return undefined;
4661
4719
  }
4662
- function codexResumeCommand(peer = "codex"): string {
4663
- const owner = bus.peers.get(peer), thread = owner instanceof CodexPeer ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4720
+ function codexResumeCommand(peer: string): string {
4721
+ const owner = bus.peers.get(peer), thread = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
4664
4722
  const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
4665
4723
  return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
4666
4724
  }
4667
4725
  function retainedTerminalNotice(peer: string): string | undefined {
4668
4726
  const owner = bus.peers.get(peer);
4669
- return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner instanceof PiPeer ? owner.mode : undefined, codexResumeCommand(peer));
4727
+ return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
4670
4728
  }
4671
4729
 
4672
4730
  async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
4673
4731
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
4674
4732
  const peer = def.path[1]!;
4675
4733
  let saved = false;
4734
+ // #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
4735
+ const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
4676
4736
  const terminalNotice = retainedTerminalNotice(peer);
4677
4737
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4678
4738
  try {
4679
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations);
4739
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
4680
4740
  if (!after) {
4681
4741
  const refusal = rosterRemovalRefusal(peer, now);
4682
4742
  if (refusal) return { ok: false, error: refusal };
@@ -4690,14 +4750,14 @@ export async function startDaemon(opts: DaemonOptions) {
4690
4750
  config.roster = next;
4691
4751
  settlePlanner(); // #455: an automatic planner follows the roster
4692
4752
  saved = true;
4693
- recordSetting(def.key, before, after, source, undo, reason);
4753
+ recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
4694
4754
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
4695
4755
  if (!after) {
4696
4756
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
4697
4757
  const owner = bus.peers.get(peer);
4698
- if (owner instanceof WsPeer) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
4699
- else if (owner instanceof CodexPeer) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
4700
- else if (owner instanceof PiPeer && owner.mode === "tui") await owner.detachForRoster();
4758
+ if (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
4759
+ else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
4760
+ else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
4701
4761
  else if (owner && owner.state !== "offline") {
4702
4762
  const result = await stopPeer(peer, true);
4703
4763
  if (result.ok !== true) throw new Error(`roster saved; ${peer} stop is unconfirmed; permission reset and task handoff were not attempted; inspect ahub status and reapply ${def.key} false to retry`);
@@ -4710,11 +4770,12 @@ export async function startDaemon(opts: DaemonOptions) {
4710
4770
  handoff = await tasks.reassignForRoster(peer);
4711
4771
  } else {
4712
4772
  const owner = bus.peers.get(peer);
4713
- if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
4773
+ if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
4714
4774
  }
4715
4775
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
4716
4776
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
4717
4777
  writeStatus();
4778
+ if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
4718
4779
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
4719
4780
  return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
4720
4781
  } catch (error) {
@@ -4727,6 +4788,12 @@ export async function startDaemon(opts: DaemonOptions) {
4727
4788
  const def = settingDef(key);
4728
4789
  if (typeof def === "string") return { error: def };
4729
4790
  const routing = activeRouting();
4791
+ if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
4792
+ // A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
4793
+ if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
4794
+ // Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
4795
+ const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
4796
+ if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
4730
4797
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
4731
4798
  catch (error) { return { error: (error as Error).message }; }
4732
4799
  }
@@ -4823,16 +4890,19 @@ export async function startDaemon(opts: DaemonOptions) {
4823
4890
  const input = settingInput(key, raw);
4824
4891
  if ("error" in input) return { ok: false, error: input.error };
4825
4892
  const { def, value } = input;
4893
+ if (def.group === "Roster" && staleRosterKey(def.key)) {
4894
+ return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
4895
+ }
4826
4896
  if (def.group === "Roster") {
4827
4897
  try {
4828
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations);
4898
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
4829
4899
  return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
4830
4900
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4831
4901
  }
4832
4902
  if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
4833
4903
  if (def.group === "Permissions") {
4834
4904
  const peer = def.path.at(-1)!;
4835
- return { ok: true, lines: [permissionBoundary(peer, declarations), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
4905
+ return { ok: true, lines: [permissionBoundary(peer, declarations, adapterCatalog), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations, adapterCatalog)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
4836
4906
  }
4837
4907
  if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
4838
4908
  try {
@@ -4927,7 +4997,7 @@ export async function startDaemon(opts: DaemonOptions) {
4927
4997
  // #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
4928
4998
  if (!peer()) return bad;
4929
4999
  const result = await hubStart(a.peer, "the dashboard");
4930
- if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).` };
5000
+ if (result.ok === true) return { ok: true, text: result.already ? `${a.peer} is already attached.` : result.opened ? `Opened a terminal running ${result.command}; ${a.peer} attaches when its TUI is ready.` : `${a.peer} started (headless).${watchSentence(result.watch)}` };
4931
5001
  // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
4932
5002
  if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
4933
5003
  log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
@@ -4963,7 +5033,7 @@ export async function startDaemon(opts: DaemonOptions) {
4963
5033
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
4964
5034
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
4965
5035
  ...(releasing ? ["ready tasks being released"] : []),
4966
- ...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && peer instanceof PiPeer && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
5036
+ ...([...bus.peers].filter(([, peer]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
4967
5037
  ],
4968
5038
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
4969
5039
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
@@ -4984,6 +5054,8 @@ export async function startDaemon(opts: DaemonOptions) {
4984
5054
  const incompatibleRecoveryTarget = () => {
4985
5055
  const cancelled = incompatibleCancellationTarget();
4986
5056
  if (cancelled) return cancelled;
5057
+ if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
5058
+ if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
4987
5059
  if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
4988
5060
  if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
4989
5061
  if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
@@ -4995,7 +5067,7 @@ export async function startDaemon(opts: DaemonOptions) {
4995
5067
  }
4996
5068
 
4997
5069
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4998
- for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
5070
+ for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
4999
5071
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
5000
5072
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
5001
5073
  }
@@ -5105,7 +5177,7 @@ export async function startDaemon(opts: DaemonOptions) {
5105
5177
  return recoveryError("unknown recovery operation");
5106
5178
  }
5107
5179
 
5108
- function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5180
+ function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5109
5181
  try {
5110
5182
  const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5111
5183
  if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
@@ -5114,15 +5186,16 @@ export async function startDaemon(opts: DaemonOptions) {
5114
5186
  }
5115
5187
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5116
5188
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
5189
+ if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
5117
5190
  if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
5118
- if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (bus.peers.get(peer) instanceof WsPeer || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
5191
+ if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
5119
5192
  if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
5120
5193
  const owner = bus.peers.get(peer);
5121
5194
  if (mode === "ask" && (!owner || owner.state === "offline")) {
5122
5195
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
5123
5196
  // to it would run a mode the status no longer shows.
5124
5197
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
5125
- const note = owner instanceof CodexPeer && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5198
+ const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5126
5199
  if (note) log(`permission mode codex: ${note}`);
5127
5200
  const from = permissionMode(peer);
5128
5201
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -5130,22 +5203,25 @@ export async function startDaemon(opts: DaemonOptions) {
5130
5203
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
5131
5204
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
5132
5205
  }
5133
- if (owner instanceof CodexPeer && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5206
+ if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5134
5207
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
5135
5208
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
5136
5209
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
5137
5210
  if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
5138
5211
  if (definitionOf(peer)?.transport === "claude-channel") {
5139
5212
  const launch = claudePermissionLaunch(peer);
5140
- if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5213
+ if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5141
5214
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
5142
- } else if (owner instanceof CodexPeer) {
5215
+ } else if (isCodexPeer(owner)) {
5143
5216
  if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5144
5217
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
5145
5218
  }
5146
5219
  const from = permissionMode(peer);
5220
+ const runtime: PeerAdapter = owner;
5221
+ const nativeMode = runtime.permissionModeState;
5222
+ if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
5147
5223
  try {
5148
- if (owner instanceof AcpPeer || owner instanceof CodexPeer) await owner.setPermissionMode(mode);
5224
+ if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
5149
5225
  } catch (error) { return { ok: false, error: (error as Error).message }; }
5150
5226
  if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
5151
5227
  permissionModes.set(peer, mode);
@@ -5158,6 +5234,7 @@ export async function startDaemon(opts: DaemonOptions) {
5158
5234
  }
5159
5235
 
5160
5236
  async function confirmPermissionDefault(peer: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5237
+ if (typeof peer === "string" && readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
5161
5238
  if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
5162
5239
  const source = pendingPermissionDefaults.get(peer)!;
5163
5240
  if (confirmed !== true) {
@@ -5167,7 +5244,7 @@ export async function startDaemon(opts: DaemonOptions) {
5167
5244
  }
5168
5245
  const owner = bus.peers.get(peer);
5169
5246
  if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
5170
- if (!starting.has(peer) && owner instanceof CodexPeer && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5247
+ if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5171
5248
  if (!starting.has(peer) && owner && owner.state !== "offline") {
5172
5249
  const result = await changePermission(peer, "never-ask", true);
5173
5250
  if (!result.ok) return result;
@@ -5198,9 +5275,9 @@ export async function startDaemon(opts: DaemonOptions) {
5198
5275
  const result = await permissionChange(peer, async () => {
5199
5276
  if (stopping) return { ok: false, error: "hub is stopping" };
5200
5277
  const selected = permissionMode(peer);
5201
- if (owner instanceof CodexPeer && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5202
- if (!(owner instanceof AcpPeer && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5203
- if (owner instanceof CodexPeer && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5278
+ if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5279
+ if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5280
+ if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5204
5281
  else await owner.setPermissionMode(selected);
5205
5282
  }
5206
5283
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -5237,7 +5314,7 @@ export async function startDaemon(opts: DaemonOptions) {
5237
5314
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5238
5315
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5239
5316
  } else {
5240
- c.peer = String(msg.peer ?? "claude");
5317
+ c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5241
5318
  if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
5242
5319
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
5243
5320
  if (!PEER_ID.test(c.peer) || (RESERVED_IDS.has(c.peer) && !kimiPull)) return sock.close(4403, definitionOf(c.peer)?.attach?.admission?.reservedRefusal ?? "peer id is reserved or malformed");
@@ -5253,9 +5330,9 @@ export async function startDaemon(opts: DaemonOptions) {
5253
5330
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
5254
5331
  }
5255
5332
  let peer = bus.peers.get(c.peer);
5256
- if (kimiPull && peer instanceof AcpPeer && peer.state === "offline") peer = undefined;
5333
+ if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
5257
5334
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
5258
- if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
5335
+ if (!isWsPeer(peer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
5259
5336
  const ws = peer;
5260
5337
  if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
5261
5338
  // Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
@@ -5321,7 +5398,7 @@ export async function startDaemon(opts: DaemonOptions) {
5321
5398
  }
5322
5399
  case "delivery_receipt": {
5323
5400
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5324
- if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
5401
+ if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
5325
5402
  if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
5326
5403
  const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
5327
5404
  bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
@@ -5331,7 +5408,7 @@ export async function startDaemon(opts: DaemonOptions) {
5331
5408
  // The attached session can explicitly claim queued work even during a native turn (#493).
5332
5409
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5333
5410
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5334
- if (c.role !== "peer" || !(peer instanceof WsPeer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
5411
+ if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
5335
5412
  try {
5336
5413
  const envs = bus.pull(peer.id);
5337
5414
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
@@ -5343,7 +5420,7 @@ export async function startDaemon(opts: DaemonOptions) {
5343
5420
  }
5344
5421
  case "delivery_complete": {
5345
5422
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
5346
- if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
5423
+ if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
5347
5424
  return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
5348
5425
  }
5349
5426
  case "execution_budget": {
@@ -5419,7 +5496,7 @@ export async function startDaemon(opts: DaemonOptions) {
5419
5496
  if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
5420
5497
  launch.session = native.sessionId;
5421
5498
  const owner = bus.peers.get(peer);
5422
- if (!(owner instanceof WsPeer) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
5499
+ if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
5423
5500
  if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
5424
5501
  launch.generation = owner.sessionGeneration;
5425
5502
  const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
@@ -5517,15 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
5517
5594
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5518
5595
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5519
5596
  const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
5520
- if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5597
+ if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5521
5598
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5522
5599
  const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
5523
5600
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5524
5601
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5525
- const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles.claude?.includes("conductor")));
5602
+ const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
5526
5603
  if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
5527
5604
  if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
5528
- if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
5605
+ if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
5529
5606
  const previous = nativeTurnStart.get(peer);
5530
5607
  const current = claudeSession(peer);
5531
5608
  if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
@@ -5548,7 +5625,7 @@ export async function startDaemon(opts: DaemonOptions) {
5548
5625
  const st = hookStats.get(peer)!;
5549
5626
  event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
5550
5627
  hookStats.delete(peer);
5551
- if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
5628
+ if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
5552
5629
  if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
5553
5630
  };
5554
5631
  if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
@@ -5557,13 +5634,13 @@ export async function startDaemon(opts: DaemonOptions) {
5557
5634
  const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
5558
5635
  const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
5559
5636
  const started = nativeTurnStart.get(peer);
5560
- const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
5637
+ const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
5561
5638
  // A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
5562
5639
  if (!activeStart) {
5563
5640
  const latest = readCompletion();
5564
5641
  if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
5565
5642
  }
5566
- if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5643
+ if (!started || !activeStart || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
5567
5644
  const scopeCurrent = () => {
5568
5645
  const now = claudeSession(peer);
5569
5646
  return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
@@ -5644,7 +5721,7 @@ export async function startDaemon(opts: DaemonOptions) {
5644
5721
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
5645
5722
  const owner = bus.peers.get(peer);
5646
5723
  const admission = definitionOf(peer)?.attach?.admission;
5647
- if (admission && !(owner instanceof AcpPeer) && (owner instanceof WsPeer || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
5724
+ if (admission && !isAcpPeer(owner) && (isWsPeer(owner) || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
5648
5725
  if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
5649
5726
  c.watch?.();
5650
5727
  c.watch = activityRings[peer]!.subscribe(item => {
@@ -5682,6 +5759,13 @@ export async function startDaemon(opts: DaemonOptions) {
5682
5759
  if (msg.op === "export") {
5683
5760
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5684
5761
  }
5762
+ if (msg.op === "accept-departure") {
5763
+ void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
5764
+ () => reply({ ok: true }),
5765
+ () => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
5766
+ );
5767
+ return;
5768
+ }
5685
5769
  if (msg.op === "accept") {
5686
5770
  // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5687
5771
  if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
@@ -5767,7 +5851,7 @@ export async function startDaemon(opts: DaemonOptions) {
5767
5851
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
5768
5852
  const peer = String(msg.peer), owner = bus.peers.get(peer);
5769
5853
  if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
5770
- peer !== definitionOf(peer)?.kind || !(owner instanceof JcodePeer) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
5854
+ peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
5771
5855
  try {
5772
5856
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
5773
5857
  const options = msg.args ?? {};