@staix/agent-hub 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
package/src/hub/daemon.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { OperationalAuthorityError, OPERATIONAL_PERSON_GUIDANCE, operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel } from "./operational-authority.ts";
1
+ import { RoomStoreError } from "../rooms/store.ts";
2
+ import { RoomCommands, type RoomPreview } from "../rooms/commands.ts";
3
+ import { queueBackpressurePolicy, type QueueBackpressurePolicy } from "./queue-backpressure.ts";
4
+ import { RoomChat } from "../rooms/service.ts";
5
+ import { parseRemotePeerId } from "../rooms/application.ts";
6
+ import { assertNativeProofPorts } from "../adapters/native-proof-ports.ts";
7
+ import { externalInstanceCatalog } from "../adapters/external-metadata.ts";
8
+ import { externalInstanceBinding, sameExternalInstanceBinding, externalInstanceStateDir, readExternalInstanceRecord, writeExternalInstanceRecord, type ExternalInstanceBinding } from "./external-instance.ts";
9
+ import { OperationalAuthorityError, OPERATIONAL_PERSON_GUIDANCE, operationalRosterSetting, operationalSetting, operationalRefusal, operationalRequestLabel, CONDUCTOR_DAEMON_OPERATIONS, CONDUCTOR_TASK_OPS, conductorLocalOperation } from "./operational-authority.ts";
2
10
  import { operationalView, redactBusEvent } from "./operational-view.ts";
3
11
  import { inspectSelectedOrphans, stopSelectedOrphan, orphanProcessPorts } from "./orphan-process.ts";
4
12
  import { dogfoodLines } from "./dogfood-read.ts";
@@ -8,6 +16,8 @@ import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolN
8
16
  import { AsyncLocalStorage } from "node:async_hooks";
9
17
  import { normalizeUsage } from "../omniroute/usage.ts";
10
18
  import { permissionPayload } from "./permission-payload.ts";
19
+ import { createClaudeFailureTrace, type ClaudeFailureTrace } from "./claude-failure-stage.ts";
20
+ import { buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH, validateNativePermissionRequest } from "./native-permission-request.ts";
11
21
  import { createClaudeLaunchProof, readClaudeLaunchProof } from "./claude-launch-proof.ts";
12
22
  import { InboxPolling } from "./inbox-polling.ts";
13
23
  import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
@@ -15,7 +25,7 @@ import { actionGate, claudeActionClasses } from "./action-enforce.ts";
15
25
  import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
16
26
  import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
17
27
  import type { DocEvidence } from "./sdlc-release.ts";
18
- import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
28
+ import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, externalModuleIdentity, externalModuleIdentityCurrent, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
19
29
  import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
20
30
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
21
31
  import { NativeHookLaunches } from "./native-hook-launch.ts";
@@ -56,8 +66,8 @@ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recove
56
66
  import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
57
67
  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";
58
68
  import type { ReviewScope } from "./review-scope.ts";
59
- import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
60
- import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
69
+ import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, isPlannerOnly, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
70
+ import { INSTANCE_CONFIG_FILES, readInstanceConfig, withoutInertInstancePolicy, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
61
71
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
62
72
  import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
63
73
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
@@ -70,10 +80,22 @@ import { spawn } from "node:child_process";
70
80
  import { childEnv } from "./child-process.ts";
71
81
  import { runCheck } from "./checks.ts";
72
82
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
83
+ import { CONTRADICTION_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, GROOM_FYI_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_FAILURE_SHARE_SETTING, SPLIT_MIN_SETTING, taskOperationalReaders } from "./operational-settings.ts";
84
+ import { BUS_DIGEST_MAX_SETTING, BUS_MAX_ATTEMPTS_SETTING, BUS_NOTES_KEPT_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING, busOperationalReaders, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING } from "./operational-settings.ts";
85
+ import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
86
+ import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
87
+ import type { ScannedFile } from "./schedules.ts";
88
+ import { corpusConfig } from "./corpus-config.ts";
73
89
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
74
- import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
90
+ import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
75
91
  import { resolveSettledRoles } from "./settled-roles.ts";
76
- import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
92
+ import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
93
+ import { issueConductorRecoveryAuthority, authorizeConductorRecovery, assertConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "./recovery-authority.ts";
94
+ import { captureRecoveryPolicy, readRecoveryPolicyInputs, recoveryRoutingFallback, assertRecoveryConfiguration, assertRecoveryRouting, assertRecoverySourceInputs, readRecoveryTargetFeatures } from "./recovery-policy.ts";
95
+ import { operationPath, recoveryLock, readOperation } from "./recovery-store.ts";
96
+ import { packageDigest, recoveryTargetDigest, verifyRecoveryTarget, exactVersion } from "../cli/recovery-package.ts";
97
+ import { planFingerprint, registeredProjects, type UpgradePlan } from "../cli/upgrade.ts";
98
+ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
77
99
  import { createConfigEditor } from "./config-edit-host.ts";
78
100
  import { readEditable } from "./config-edit.ts";
79
101
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
@@ -84,7 +106,7 @@ import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
84
106
  import { ExecutionBudget } from "./execution-budget.ts";
85
107
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
86
108
  import type { ServerWebSocket } from "bun";
87
- import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
109
+ import { isAcpPeer, managedAcpLaunch, verifyManagedAcpLaunch, type AcpOptions, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
88
110
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
89
111
  import { isPiPeer, PiVersionRefusal, type PiPeer } from "../adapters/pi.ts";
90
112
  import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
@@ -92,17 +114,18 @@ import { startModelRelay, type ModelRelay } from "../models/relay.ts";
92
114
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
93
115
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
94
116
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
95
- import { processSignature } from "../pi/process-signature.ts";
117
+ import { processSignature, processLiveness } from "../pi/process-signature.ts";
96
118
  import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
97
119
  import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
98
120
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
121
+ import { webFetchAllow } from "../local/web-fetch-config.ts";
99
122
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext, type ShellPermitPolicy } from "../local/tools.ts";
100
123
  import { proveReadOnlyShell, ShellExecutionProofs } from "./read-only-shell-proof.ts";
101
124
  import { createHostShellWrapper, type HostShellWrapper } from "./claude-shell-wrapper.ts";
102
125
  import { readTaskShellDefaultBranch, readTaskShellBinding, proveTaskShell } from "./task-shell-proof.ts";
103
126
  import { taskShellSandboxRestrictions } from "./task-shell-sandbox.ts";
104
127
  import { hostShellEnvironment } from "../local/shell-environment.ts";
105
- import { hubGitArgv, hubGitEnv } from "./git.ts";
128
+ import { hubGitArgv, hubGitEnv, hubGitSync } from "./git.ts";
106
129
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
107
130
  import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
108
131
  import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
@@ -110,7 +133,9 @@ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
110
133
  import { reviewDiff } from "../adapters/claude-print.ts";
111
134
  import { Capture } from "../memory/capture.ts";
112
135
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
113
- import { Briefs } from "../memory/brief.ts";
136
+ import { CorpusIndex } from "./corpus.ts";
137
+ import { corpusEvidence } from "./corpus-evidence.ts";
138
+ import { Briefs, type BriefTask } from "../memory/brief.ts";
114
139
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
115
140
  import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
116
141
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
@@ -123,15 +148,16 @@ import { realPath, hubHome } from "./project.ts";
123
148
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
124
149
  import type { BusEvent, PublishResult } from "./bus.ts";
125
150
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
126
- import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
151
+ import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, taskShowAllowed, type ConductEvent } from "./conductor.ts";
127
152
  import { SupervisionFeed } from "./supervision.ts";
128
153
  import { drainCliAudits } from "../cli/identity-audit.ts";
129
154
  import { launcherPreview } from "../cli/preview.ts";
130
155
  import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
131
- import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
132
- import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
156
+ import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig, type PiiVerdict } from "./inference.ts";
157
+ import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
133
158
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
134
- import { NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
159
+ import { withChecklistLine } from "./learning.ts";
160
+ import { LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
135
161
  import { githubOriginRepository } from "./task-repository.ts";
136
162
  import { Bus } from "./bus.ts";
137
163
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
@@ -141,6 +167,7 @@ import { codeGroup } from "./task-code-group.ts";
141
167
  import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
142
168
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
143
169
  import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
170
+ import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
144
171
  import { MemoryClient } from "../memory/client.ts";
145
172
  import { VERSION } from "../version.ts";
146
173
  import { appendRecords, taskRecords } from "./research.ts";
@@ -152,10 +179,10 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
152
179
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
153
180
  import type { JournalDelivery } from "./delivery-journal.ts";
154
181
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
155
- 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";
182
+ import { soleWorktree, branchHeads, changedPaths, dirtySubmodules, submodulePaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
156
183
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
157
184
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
158
- import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
185
+ import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
159
186
  import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
160
187
 
161
188
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
@@ -169,7 +196,8 @@ import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type Can
169
196
  import { memoryProjectKey } from "@staix/agent-mem";
170
197
  import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
171
198
  import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
172
- import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
199
+ import { SourceRegistry } from "./sources.ts";
200
+ import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
173
201
  import { nativeRecall } from "../memory/recall.ts";
174
202
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
175
203
 
@@ -193,6 +221,8 @@ export interface HubConfig {
193
221
  batch_max: number;
194
222
  batch_ms: number;
195
223
  queue_cap: number;
224
+ /** #489 H3: accepted delivery policy, read at the next delivery operation without rewriting queued work. */
225
+ bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number };
196
226
  /** `native` (#258): the hub's own store in ~/.agenthub/memory, on with any project config; `short_ttl_days`: short-term lifetime after approval; `sync_interval_min`: machine sync while a remote is set (default 10, 0 off). */
197
227
  memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
198
228
  roster: PeerRoster;
@@ -204,6 +234,8 @@ export interface HubConfig {
204
234
  pii?: { paths: string[] };
205
235
  data?: { readonly: string[] };
206
236
  text_projection?: ProjectionConfig;
237
+ /** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
238
+ corpus: { paths: string[] };
207
239
  catalog_roles?: Record<string, string[]>;
208
240
  conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
209
241
  budget: BudgetConfig;
@@ -213,13 +245,17 @@ export interface HubConfig {
213
245
  pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
214
246
  mlx: { enabled: boolean } & Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
215
247
  /** `bash_network`: true is network through the egress proxy to `network_allow` (#65); "direct" is everything, until 0.13.0 (#83). */
216
- local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[] };
248
+ local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[]; /** #515: offer the local worker `doc_read` (PDF, DOCX, HWPX through `text_projection`); off by default, machine-local. */ doc_read: boolean; /** #515b: the hosts the local worker's `web_fetch` may read; empty (the default) means the tool does not exist. Machine-local. */ web_fetch: { allow: string[] } };
217
249
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
218
250
  approvals: { timeout_s: number; notify: boolean };
219
251
  /** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
220
- tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number; /** #785 P1c: pull the top explicitly reserved ready card when a slot frees; machine-local, default off. */ auto_pull?: boolean };
252
+ tasks: { release_after_min: number; wip_limits?: Record<string, number>; escalate_after: number; groom_fyi_window_min?: number; demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number; /** #785 P1c: pull the top explicitly reserved ready card when a slot frees; machine-local, default off. */ auto_pull?: boolean; queue_backpressure?: Partial<QueueBackpressurePolicy> };
221
253
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
222
254
  task_sweep: TaskSweepConfig;
255
+ /** #512a: machine-local schedules; checked by `schedulesConfig`, never set from a committed file. */
256
+ schedules: ScheduleEntry[];
257
+ /** #512c: machine-local folder triggers, polled on the schedules timer; checked by `foldersConfig`, never set from a committed file. */
258
+ folder_triggers: FolderEntry[];
223
259
  /** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
224
260
  checks: { timeout_s: number; runner?: "container" | "remote"; remote?: RemoteRunnerConfig; [cls: string]: string | number | RemoteRunnerConfig | undefined };
225
261
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
@@ -237,7 +273,8 @@ export interface HubConfig {
237
273
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
238
274
  review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
239
275
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
240
- recovery: { auto_resume_after_crash: boolean };
276
+ /** #489 H4: the five waits the upgrade coordinator reads from this project's config at the start of each wait; the daemon itself only validates and carries them. */
277
+ recovery: { auto_resume_after_crash: boolean; reconnect_wait_s?: number; unmanaged_reconnect_wait_s?: number; control_timeout_s?: number; shutdown_wait_s?: number; terminal_idle_wait_s?: number };
241
278
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
242
279
  capabilities: Record<string, string[]>;
243
280
  /**
@@ -265,6 +302,7 @@ export const DEFAULT_CONFIG: HubConfig = {
265
302
  batch_max: 3,
266
303
  batch_ms: 15_000,
267
304
  queue_cap: 200,
305
+ bus: { max_attempts: BUS_MAX_ATTEMPTS_SETTING.defaultValue, digest_max: BUS_DIGEST_MAX_SETTING.defaultValue, pull_budget_bytes: BUS_PULL_BUDGET_BYTES_SETTING.defaultValue, notes_kept: BUS_NOTES_KEPT_SETTING.defaultValue, terminal_history_limit: BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue },
268
306
  memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
269
307
  roster: {},
270
308
  roles: DEFAULT_ROLES,
@@ -278,8 +316,11 @@ export const DEFAULT_CONFIG: HubConfig = {
278
316
  ...nativeDefaults.sandbox,
279
317
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
280
318
  approvals: { timeout_s: 120, notify: false },
281
- tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER, auto_pull: false },
319
+ tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER, groom_fyi_window_min: GROOM_FYI_WINDOW_SETTING.defaultValue, demote_at: DEMOTE_AT_SETTING.defaultValue, demotion_half_life_h: DEMOTION_HALF_LIFE_SETTING.defaultValue, contradiction_window_days: CONTRADICTION_WINDOW_SETTING.defaultValue, rescreen_max: RESCREEN_MAX_SETTING.defaultValue, split_min_observations: SPLIT_MIN_SETTING.defaultValue, split_max_failure_share: SPLIT_FAILURE_SHARE_SETTING.defaultValue, auto_pull: false },
282
320
  task_sweep: DEFAULT_TASK_SWEEP,
321
+ schedules: [],
322
+ folder_triggers: [],
323
+ corpus: { paths: [] },
283
324
  checks: { timeout_s: 600 },
284
325
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
285
326
  snapshots: { enabled: false, keep: 20 },
@@ -289,7 +330,7 @@ export const DEFAULT_CONFIG: HubConfig = {
289
330
  terminal: { open: [] },
290
331
  limits: DEFAULT_LIMITS,
291
332
  review: { adaptive: false, min_reviews: 5 },
292
- recovery: { auto_resume_after_crash: false },
333
+ recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
293
334
  capabilities: {},
294
335
  coordination: "advisory",
295
336
  };
@@ -297,14 +338,14 @@ export const DEFAULT_CONFIG: HubConfig = {
297
338
  export { stateDirFor };
298
339
 
299
340
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
300
- 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"]);
341
+ 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_source_read", "hub_status"]);
301
342
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
302
343
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
303
344
 
304
345
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
305
346
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
306
347
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
307
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
348
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
308
349
 
309
350
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
310
351
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
@@ -381,7 +422,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
381
422
  let file: Record<string, any>; // parsed JSON, checked field by field below
382
423
  if (!captured.some(layer => layer.name === name)) return [];
383
424
  try {
384
- file = structuredClone(captured.find(layer => layer.name === name)!.raw);
425
+ file = withoutInertInstancePolicy(structuredClone(captured.find(layer => layer.name === name)!.raw), instanceConfig.declaredExternalInstances);
385
426
  } catch (error) {
386
427
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
387
428
  throw error;
@@ -394,6 +435,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
394
435
  profileName(file.profile);
395
436
  resolveProfile(file.profile, file.profile_overrides);
396
437
  projectionConfig(file.text_projection);
438
+ corpusConfig(file.corpus);
439
+ queueBackpressurePolicy(file.tasks?.queue_backpressure);
440
+ validateOperationalConfig(file);
397
441
  for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
398
442
  const raw = file[block];
399
443
  if (raw !== undefined && (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).some(k => k !== key))) throw new Error(block + " must be a bounded path policy block");
@@ -411,6 +455,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
411
455
  terminalTemplate(file.terminal?.open);
412
456
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
413
457
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
458
+ delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
414
459
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
415
460
  if (file.checks?.runner === "remote" || file.checks?.remote !== undefined) {
416
461
  if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
@@ -423,6 +468,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
423
468
  });
424
469
  if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
425
470
  const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
471
+ const pressureLayers = files.filter(layer => layer.tasks?.queue_backpressure !== undefined);
472
+ if (pressureLayers.length) file.tasks = { ...file.tasks, queue_backpressure: pressureLayers.reduce((prior, layer) => ({ ...prior, ...layer.tasks.queue_backpressure }), {}) };
426
473
  const wipLayers = files.filter(layer => layer.tasks?.wip_limits !== undefined);
427
474
  if (wipLayers.length) file.tasks = { ...file.tasks, wip_limits: wipLayers.reduce((prior, layer) => ({ ...prior, ...planningWipLimits(layer.tasks.wip_limits) }), {} as Record<string, number>) };
428
475
  const overrides = files.reduce<unknown>((prior, layer) => mergeProfileOverrides(prior, layer.profile_overrides), undefined);
@@ -478,7 +525,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
478
525
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
479
526
  ...(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)
480
527
  roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
481
- roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
528
+ roles: Object.fromEntries(Object.entries({ ...configuredRoles, ...conductorPolicy.roles, ...extraRoles }).filter(([id]) => !instanceConfig.declaredExternalInstances?.some(row => row.id === id || row.kind === id))),
482
529
  planner: conductorPolicy.planner,
483
530
  conductor: conductorPolicy.conductor,
484
531
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -491,7 +538,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
491
538
  notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
492
539
  },
493
540
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
541
+ bus: { ...DEFAULT_CONFIG.bus, ...file.bus },
494
542
  task_sweep: taskSweepConfig(file.task_sweep),
543
+ schedules: schedulesConfig(file.schedules),
544
+ folder_triggers: foldersConfig(file.folder_triggers),
545
+ corpus: corpusConfig(file.corpus),
495
546
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
496
547
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
497
548
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
@@ -534,6 +585,8 @@ export interface DaemonOptions {
534
585
  memoryClient?: MemoryClient;
535
586
  /** Host-only remote-default evidence seam; never read from config or control messages. */
536
587
  shellDefaultBranchProbe?: typeof readTaskShellDefaultBranch;
588
+ /** Host-only workspace-validation Git seam; never supplied by project config or control messages. */
589
+ instanceExecutionGit?: typeof hubGitSync;
537
590
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
538
591
  snapshot?: typeof snapshot;
539
592
  snapshotBurstMs?: number;
@@ -580,8 +633,28 @@ export interface DaemonOptions {
580
633
  agentDriftEveryMs?: number;
581
634
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
582
635
  bandsHeld?: () => boolean;
636
+ /** Test seam: the schedules clock and the period of the due check (default Date.now and one minute). */
637
+ schedulesNow?: () => number;
638
+ schedulesEveryMs?: number;
639
+ /** Test seam: an extra hold on the schedules timer, like a storage error. */
640
+ schedulesHeld?: () => boolean;
641
+ /** Test seam: replaces the PII screen's model call, so a test can hold a screen open. */
642
+ piiScreen?: (text: string) => Promise<PiiVerdict>;
643
+ /** Test seam: how often the scheduled security-scan baseline checks whether a scan is due, default one hour (the configured `every_h` still decides). */
644
+ securityScanEveryMs?: number;
583
645
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
584
646
  researchGraceMs?: number;
647
+ /**
648
+ * Host-only test seam (#546), never read from config or a control message: `false` leaves the config file watcher off, so an
649
+ * explicit `settings_reload` is the only publisher. With it on, the watcher and a reload of the same bytes race: whichever runs
650
+ * second reports `applied: []`, and a watcher pass between a preview and its confirm replaces the confirmation token.
651
+ */
652
+ configWatch?: boolean;
653
+ /**
654
+ * Host-only test seam (#553), never read from config or a control message: the transport the `web_fetch` tool uses instead of the global `fetch`, so a daemon test
655
+ * does not make a real round trip to a public host. The tool still gets the daemon's own proxy URL and allowlist; only the request itself is replaced.
656
+ */
657
+ webFetchTransport?: import("../local/web-fetch.ts").WebFetchPort["fetch"];
585
658
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
586
659
  memorySync?: typeof syncMemory;
587
660
  /** Host-only test/embedding transports. Never read from config or tool input. */
@@ -597,6 +670,7 @@ interface Client {
597
670
  role?: "peer" | "console" | "tools";
598
671
  peer?: PeerId;
599
672
  launchId?: string;
673
+ externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
600
674
  toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
601
675
  tail?: () => void;
602
676
  watch?: () => void;
@@ -628,22 +702,59 @@ export function resolutionRefusal(error: unknown): string {
628
702
  /** No remote set and a stop are not failures: agent-mem words the missing remote "no sync remote" (v1) or "no generation sync remote configured". */
629
703
  export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
630
704
 
705
+ let recoverySourcePackageDigest: string | null | undefined;
706
+ function capturedRecoverySourceDigest(): string | undefined {
707
+ if (recoverySourcePackageDigest === undefined) {
708
+ try { recoverySourcePackageDigest = packageDigest(resolve(import.meta.dir, "../..")); }
709
+ catch { recoverySourcePackageDigest = null; }
710
+ }
711
+ return recoverySourcePackageDigest ?? undefined;
712
+ }
713
+
631
714
  export async function startDaemon(opts: DaemonOptions) {
715
+ const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
716
+ resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
632
717
  const projectId = opts.projectId ?? randomUUID();
718
+ const sourcePackageDigest = capturedRecoverySourceDigest();
633
719
  const instanceId = opts.instanceId ?? randomUUID();
634
720
  const startupCleanup: (() => void | Promise<void>)[] = [];
635
721
  let ready = false;
636
722
  try {
637
723
  let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
638
- try { startupLayers = readInstanceConfigLayers(opts.cwd); }
639
- catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
724
+ let recoveryConfiguration: HubConfig | undefined, recoveryRouting: Routing | undefined, recoverySources: ConfigSourceTexts | undefined, recoveryDefaultRouting: string | undefined;
725
+ const recoveryInitiator = process.env.AGENTHUB_RECOVERY_CONDUCTOR;
726
+ if (recoveryInitiator !== undefined) {
727
+ // Before module imports or factories: the exact startup snapshot must match the source-issued policy.
728
+ try {
729
+ const operationId = process.env.AGENTHUB_RECOVERY_OPERATION;
730
+ if (!operationId) throw new RecoveryAuthorityError();
731
+ const operation = readOperation<import("../cli/upgrade.ts").RecoveryOperation>(operationId);
732
+ if (!operation || operation.id !== operationId) throw new RecoveryAuthorityError();
733
+ const snapshot = readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId });
734
+ if (!snapshot) throw new RecoveryAuthorityError();
735
+ const inputs = readRecoveryPolicyInputs(opts.cwd, operation.conductorRoutingDefaults?.[projectId]);
736
+ const candidate = loadConfig(opts.cwd, inputs.layers, inputs.routing.retired ?? [], []);
737
+ const expected = conductorRecoveryBinding(operation, { root: opts.cwd, id: projectId, stateDir: opts.stateDir }, recoveryInitiator, { config: candidate, inputs });
738
+ if (expected.sourceInstanceId !== snapshot.sourceInstanceId || expected.targetRoot !== resolve(import.meta.dir, "../..") || expected.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
739
+ const proof = authorizeConductorRecovery(opts.stateDir, expected, () => { const value = captureRecoveryPolicy({ root: opts.cwd, id: projectId, stateDir: opts.stateDir }, recoveryInitiator, candidate, inputs); return value.policyDigest === expected.policyDigest && value.boardDigest === expected.boardDigest; });
740
+ assertConductorRecovery(proof);
741
+ startupLayers = inputs.layers; recoveryConfiguration = candidate; recoveryRouting = inputs.routing; recoverySources = inputs.texts; recoveryDefaultRouting = inputs.routingFallback;
742
+ } catch { throw new RecoveryAuthorityError(); }
743
+ delete process.env.AGENTHUB_RECOVERY_CONDUCTOR;
744
+ delete process.env.AGENTHUB_RECOVERY_CONTINUATION;
745
+ if (process.env.AGENTHUB_PEER_ID === recoveryInitiator) delete process.env.AGENTHUB_PEER_ID;
746
+ } else {
747
+ try { startupLayers = readInstanceConfigLayers(opts.cwd); }
748
+ catch (error) { if (!opts.config) throw error; startupLayers = []; }
749
+ } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
640
750
  const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
641
751
  const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
642
752
  const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
643
753
  const externalIds = new Set(externalAdapters.map(item => item.kind));
644
754
  const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
645
- const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
646
- : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
755
+ const startupDeclarationRows = readInstanceConfig(opts.cwd, startupLayers, externalDefinitions).declaredExternalInstances ?? [];
756
+ const loadedConfig = recoveryConfiguration ?? (opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
757
+ : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions));
647
758
  reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
648
759
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
649
760
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
@@ -663,6 +774,9 @@ export async function startDaemon(opts: DaemonOptions) {
663
774
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
664
775
  const implementerTranscripts = new Map<string, string>();
665
776
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
777
+ const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
778
+ const externalBindings = new Map<string, ExternalInstanceBinding>();
779
+ const externalSessions = new Map<string, string>();
666
780
  const verifiedTerminalRootsThisTurn = new Set<object>();
667
781
  const reviewerUnavailable = new Map<string, string>();
668
782
  const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
@@ -686,6 +800,66 @@ export async function startDaemon(opts: DaemonOptions) {
686
800
  roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
687
801
  // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
688
802
  const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
803
+ const externalInstanceIds = extraIds.filter(id => externalIds.has(declarations[id]!.kind));
804
+ // Instance declarations and their effective role ceiling are restart-bound, even for a fresh native session.
805
+ const externalStartupPolicies = new Map(externalInstanceIds.map(id => [id, { declaration: structuredClone(declarations[id]!), roles: [...(config.roles[id] ?? [])] }]));
806
+ const externalRefusedRows = new Map(startupDeclarationRows.map(row => [row.id, row]));
807
+ const allExternalInstanceIds = () => [...new Set([...(restored?.peers ?? []).filter(row => row.externalInstance).map(row => row.id), ...externalInstanceIds, ...externalRefusedRows.keys()])];
808
+ const isExternalInstance = (peer: string) => externalInstanceIds.includes(peer);
809
+ // ponytail: each instance costs about 10 synchronous Git calls per fresh turn (64 instances maximum);
810
+ // a stat-keyed evidence cache is the upgrade path, preserving fresh effect-boundary rechecks.
811
+ const externalEvidenceThisTurn = new Map<string, { binding: ExternalInstanceBinding; workspace: string } | undefined>();
812
+ let externalEvidenceExpiryQueued = false;
813
+ function externalEvidence(peer: string, fresh = false): { binding: ExternalInstanceBinding; workspace: string } | undefined {
814
+ if (!fresh && externalEvidenceThisTurn.has(peer)) return externalEvidenceThisTurn.get(peer);
815
+ let evidence: { binding: ExternalInstanceBinding; workspace: string } | undefined;
816
+ try {
817
+ if (externalRefusedRows.has(peer)) throw new Error("external instance module refused");
818
+ const binding = captureExternalBinding(peer);
819
+ const retained = externalBindings.get(peer);
820
+ if (retained && !sameExternalInstanceBinding(retained, binding)) throw new Error("external instance binding changed");
821
+ evidence = { binding, workspace: externalWorkspaceIdentity(binding.executionCwd) };
822
+ externalBindings.set(peer, binding);
823
+ } catch { /* Unavailable evidence is cached only for this synchronous turn, never authority. */ }
824
+ externalEvidenceThisTurn.set(peer, evidence);
825
+ if (!externalEvidenceExpiryQueued) {
826
+ externalEvidenceExpiryQueued = true;
827
+ queueMicrotask(() => { externalEvidenceThisTurn.clear(); externalEvidenceExpiryQueued = false; });
828
+ }
829
+ return evidence;
830
+ }
831
+ function externalWorkspaceIdentity(cwd: string): string {
832
+ const info = lstatSync(cwd);
833
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("external workspace directory changed");
834
+ const branch = hubGitSync(["-C", cwd, "symbolic-ref", "-q", "HEAD"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
835
+ if (branch.error || branch.status !== 0 && branch.status !== 1) throw new Error("external workspace branch evidence unavailable");
836
+ return `${info.dev}:${info.ino}:${branch.status === 0 ? branch.stdout.trim() : "detached"}`;
837
+ }
838
+ function captureExternalBinding(peer: string, resumeSessionId?: string): ExternalInstanceBinding {
839
+ const definition = definitionOf(peer), identity = definition && externalModuleIdentity(definition);
840
+ if (!identity || !externalModuleIdentityCurrent(definition!)) throw new Error("external instance module binding changed");
841
+ const current = readInstanceConfig(opts.cwd, undefined, externalDefinitions);
842
+ const declaration = current.instances?.[peer];
843
+ if (!declaration || declaration.kind !== declarations[peer]?.kind) throw new Error("external instance declaration changed");
844
+ const policy = loadConductorPolicy(opts.cwd, undefined, current);
845
+ const currentRoles = effectiveInstanceRoles(peer, current.instances, policy.roles, current.catalog);
846
+ const cwd = resolveExecutionCwd(peer, current.instances, currentRoles, current.catalog);
847
+ const binding = externalInstanceBinding(identity, peer, declaration, currentRoles, cwd);
848
+ const startup = externalStartupPolicies.get(peer);
849
+ if (!startup || binding.policyDigest !== externalInstanceBinding(identity, peer, startup.declaration, startup.roles, cwd).policyDigest) throw new Error("external instance policy changed; restart the hub before starting under its new policy");
850
+ const record = readExternalInstanceRecord(opts.stateDir, peer);
851
+ const sameBinding = record && sameExternalInstanceBinding(record.externalInstance, binding);
852
+ if (resumeSessionId !== undefined) {
853
+ if (record && !sameBinding) throw new Error("external instance retained record binding changed");
854
+ if (!record || record.sessionId !== resumeSessionId) throw new Error("external instance resume requires its recorded native session id");
855
+ }
856
+ if (sameBinding) externalSessions.set(peer, record.sessionId);
857
+ else externalSessions.delete(peer); // Changed continuity does not prevent a person's fresh session. Successful start replaces the record.
858
+ return binding;
859
+ }
860
+ function retainedExternalBinding(peer: string, fresh = false): ExternalInstanceBinding | undefined {
861
+ return externalEvidence(peer, fresh)?.binding;
862
+ }
689
863
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
690
864
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
691
865
  const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
@@ -694,8 +868,29 @@ export async function startDaemon(opts: DaemonOptions) {
694
868
  const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
695
869
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
696
870
  const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
871
+ /** Display the same runtime form/role ceilings used by declarations; these fields grant no authority. */
872
+ function roleVisibility(peer: string): { form?: string; capableRoles: string[] } {
873
+ const definition = definitionOf(peer), declaration = declarations[peer];
874
+ if (!definition) return { capableRoles: [] };
875
+ const fixed = isFixedBuiltinDefinition(definition);
876
+ const candidates: readonly string[] = fixed ? definition.routing?.reviewOnly ? definition.roles : definition.instances?.roles ?? ["planner", "implementer", "verifier", "reviewer", "conductor"]
877
+ : declaration ? definition.headlessInstances?.roles ?? definition.roles : definition.roles;
878
+ const capableRoles = candidates.filter(role => (!definition.routing?.allowedRoles || definition.routing.allowedRoles.includes(role))
879
+ && (role !== "planner" || (fixed ? definition.projection?.plannerEligible === true : !declaration && definition.roles.includes(role)))
880
+ && (fixed || role !== "conductor"));
881
+ const owner = bus.peers.get(peer);
882
+ const form = !declaration ? owner && "form" in owner ? owner.form : startMode(peer) : definition.instances?.mode === "external"
883
+ ? (config.roles[peer] ?? []).some(role => role === "implementer" || role === "verifier") ? "terminal-implementer" : "channel" : "headless";
884
+ return { form, capableRoles: [...capableRoles] };
885
+ }
886
+
697
887
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
698
888
  const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
889
+ const holderReasons = (view: Record<string, unknown>) => ({
890
+ ...(typeof view.plannerReason === "string" ? { plannerReason: view.plannerReason } : {}),
891
+ ...(typeof view.ownerReason === "string" ? { ownerReason: view.ownerReason } : {}),
892
+ ...(typeof view.reviewerReason === "string" ? { reviewerReason: view.reviewerReason } : {}),
893
+ });
699
894
  /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
700
895
  const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => {
701
896
  if (!reviewOnlyPeer(peer) || !Array.isArray(view.history)) return view;
@@ -723,6 +918,8 @@ export async function startDaemon(opts: DaemonOptions) {
723
918
  const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
724
919
  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;
725
920
  };
921
+ // #514b: undo resolves the managed checklist key, which is not a settings row, and every other key through the project-scoped lookup above (declared instances, the adapter catalog, a stale roster key).
922
+ const undoableDef = (key: unknown): SettingDef | string => key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
726
923
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
727
924
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
728
925
  let rosterChanging: string | undefined;
@@ -733,7 +930,8 @@ export async function startDaemon(opts: DaemonOptions) {
733
930
  const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
734
931
  const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
735
932
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
736
- const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
933
+ const remoteRoster = new Set<string>();
934
+ const membershipAllowed = (peer: string) => peer.includes("@") ? remoteRoster.has(peer) : definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
737
935
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
738
936
  const mlxConfigProblem = mlxLaunchProblem(config, config.pi) ?? disabledMlxPolicyProblem(config, opts.cwd);
739
937
  if (mlxConfigProblem) throw new Error(mlxConfigProblem);
@@ -747,8 +945,10 @@ export async function startDaemon(opts: DaemonOptions) {
747
945
  let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
748
946
  for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");view??=publicOperationalView();publicLine??=view.line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine,complete:view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:{})}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
749
947
  };
750
- let runningRouting = currentRouting(opts.cwd, log);
751
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
948
+ let runningRoutingDefault = recoveryDefaultRouting;
949
+ if (runningRoutingDefault === undefined) try { runningRoutingDefault = recoveryRoutingFallback(); } catch { /* unknown defaults refuse conductor recovery */ }
950
+ let runningRouting = recoveryRouting ?? currentRouting(opts.cwd, log);
951
+ let acceptedSources = parseConfigSources(recoverySources ?? configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters, externalDefinitions);
752
952
  const startupSources = structuredClone(acceptedSources.trustLayers);
753
953
  const embeddedConfig = structuredClone(config);
754
954
  let desiredConfig = structuredClone(config);
@@ -945,6 +1145,16 @@ export async function startDaemon(opts: DaemonOptions) {
945
1145
  ? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
946
1146
  : undefined;
947
1147
  if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
1148
+ for (const peer of restored?.peers ?? []) if (peer.externalInstance) {
1149
+ try {
1150
+ if (!isExternalInstance(peer.id) || !sameExternalInstanceBinding(peer.externalInstance, captureExternalBinding(peer.id))) throw new Error("external instance retained binding is unavailable");
1151
+ } catch {
1152
+ // Preserve historical correlation/integrity while refusing this instance alone. Builtins still restore.
1153
+ externalRefusedRows.set(peer.id, { id: peer.id, kind: peer.externalInstance.kind, availability: "refused", refusalCode: "module-refused" });
1154
+ }
1155
+ externalBindings.set(peer.id, peer.externalInstance);
1156
+ if (peer.sessionId) externalSessions.set(peer.id, peer.sessionId);
1157
+ }
948
1158
 
949
1159
  // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
950
1160
  const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
@@ -971,7 +1181,8 @@ export async function startDaemon(opts: DaemonOptions) {
971
1181
 
972
1182
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
973
1183
  let inference: Inference | undefined;
974
- const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
1184
+ const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
1185
+ const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
975
1186
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
976
1187
  const lost = new Map<PeerId, JournalDelivery[]>();
977
1188
  if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
@@ -1030,6 +1241,15 @@ export async function startDaemon(opts: DaemonOptions) {
1030
1241
  log(`network: egress proxy on 127.0.0.1:${egress.port}, ${allow.length} allowed host(s) (local.network_allow)`);
1031
1242
  }
1032
1243
  const sandboxNetwork: SandboxNetwork = config.local.bash_network === "direct" ? true : egress ? { proxyPort: egress.port } : false;
1244
+ // #515b: `web_fetch` has its own egress proxy, whose allowlist is `local.web_fetch.allow` and nothing else. It never shares the bash proxy (whose list is `network_allow`: package
1245
+ // registries and code hosts), so listing a host for the model to read opens nothing to a shell command, and the reverse. Started only when the project listed a host.
1246
+ const webAllow = () => webFetchAllow(config.local.web_fetch?.allow);
1247
+ let webEgress: EgressProxy | undefined;
1248
+ if (webAllow().length) {
1249
+ webEgress = await startEgressProxy({ allow: webAllow(), log, allowFn: webAllow });
1250
+ startupCleanup.push(() => void webEgress?.close());
1251
+ log(`network: web_fetch egress proxy on 127.0.0.1:${webEgress.port}, ${webAllow().length} allowed host(s) (local.web_fetch.allow)`);
1252
+ }
1033
1253
  const admit = (env: Envelope, parent?: string): string | undefined => {
1034
1254
  // [FYI] is recorded and costs nobody a turn: nothing to limit.
1035
1255
  if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
@@ -1079,7 +1299,20 @@ export async function startDaemon(opts: DaemonOptions) {
1079
1299
  const staleOff = () => config.experiments?.stale_notices === "deliver";
1080
1300
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
1081
1301
  const requestedPeerStops = new Set<string>();
1082
- 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,
1302
+ const queuePolicy = () => queueBackpressurePolicy(config.tasks.queue_backpressure);
1303
+ let remotePending = (_room: string): readonly string[] => [];
1304
+ let remoteQueueChanged = () => {};
1305
+ const bus: Bus = new Bus({ rostered: membershipAllowed, ...busReaders, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, 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,
1306
+ remotePending: room => remotePending(room),
1307
+ remotePrivate: (env): boolean => {
1308
+ const task = env.refs?.task;
1309
+ if (task !== undefined) {
1310
+ if (typeof task !== "string" || !/^[1-9][0-9]*$/.test(task) || !Number.isSafeInteger(Number(task))) return true;
1311
+ const row = board.get(Number(task)); if (!row || tasks.isPii(row)) return true;
1312
+ }
1313
+ return env.from === USER ? false : !bus.peers.has(env.from) || !!turns.get(env.from)?.private || contextPii(env.from);
1314
+ },
1315
+ doing: (peer, kind, text) => observeDoing(peer, kind, text),
1083
1316
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1084
1317
  startupCleanup.push(() => bus.closeJournal());
1085
1318
  const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
@@ -1504,7 +1737,7 @@ export async function startDaemon(opts: DaemonOptions) {
1504
1737
  const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
1505
1738
  const connectionGeneration = (id: string): string | undefined => {
1506
1739
  const peer = bus.peers.get(id);
1507
- if (!peer || peer.state === "offline") return undefined;
1740
+ if (!peer || peer.state === "offline" || isExternalInstance(id) && !currentExecutionOwner(id)) return undefined;
1508
1741
  let token = checkConnections.get(peer);
1509
1742
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1510
1743
  const hasGeneration = "sessionGeneration" in peer;
@@ -1520,6 +1753,25 @@ export async function startDaemon(opts: DaemonOptions) {
1520
1753
  };
1521
1754
  const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
1522
1755
  && !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
1756
+ let corpusRuntime: { key: string; index: CorpusIndex } | undefined;
1757
+ const corpusPolicyVersion = () => JSON.stringify({ revision: configRevision, paths: config.corpus.paths, pii: config.pii?.paths ?? [], deny: config.local.deny, signals: activeRouting().signals });
1758
+ const publicCorpus = (query: string, allowed: () => boolean) => {
1759
+ if (!allowed() || !config.corpus.paths.length) return [];
1760
+ const key = corpusPolicyVersion();
1761
+ if (!corpusRuntime || corpusRuntime.key !== key) corpusRuntime = { key, index: new CorpusIndex({ cwd: opts.cwd, paths: [...config.corpus.paths], piiPaths: [...config.pii?.paths ?? []], deny: [...config.local.deny], piiPatterns: [...activeRouting().signals.pii_patterns] }) };
1762
+ return corpusEvidence(query, corpusRuntime.index, {
1763
+ allowed, version: corpusPolicyVersion,
1764
+ safe: (text, paths) => boundedSafe(text) && !mentionsPrivatePath(text, paths.map(path => `path:${path}`), [...config.pii?.paths ?? [], ...config.local.deny], opts.cwd),
1765
+ });
1766
+ };
1767
+ const corpusBriefAllowed = (task: BriefTask) => {
1768
+ const latest = task.id === undefined ? undefined : board.get(task.id);
1769
+ return !stopping && !!latest && latest.created === task.created && latest.updated === task.updated && latest.owner === task.owner
1770
+ && (!task.history || latest.history.length === task.history.length) && !tasks.isPii(latest)
1771
+ && !turns.get(latest.owner ?? "")?.private && !contextPii(latest.owner ?? "")
1772
+ && boundedSafe(JSON.stringify({ title: task.title, refs: task.refs, plan: task.plan }));
1773
+ };
1774
+
1523
1775
  const publicCheck = (receipt: RunReceipt) => {
1524
1776
  const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
1525
1777
  return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
@@ -1618,6 +1870,12 @@ export async function startDaemon(opts: DaemonOptions) {
1618
1870
  const tasks: Tasks = new Tasks({
1619
1871
  releaseWindow,
1620
1872
  capabilities: peerCapabilities, repository: memoryRepo,
1873
+ contextAdmission: () => ({
1874
+ policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
1875
+ peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
1876
+ contexts.admission(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer))),
1877
+ ])) : {},
1878
+ }),
1621
1879
  plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1622
1880
  const codes: ("approvals" | "quota")[] = [];
1623
1881
  if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
@@ -1634,6 +1892,8 @@ export async function startDaemon(opts: DaemonOptions) {
1634
1892
  const scope = childToolAuthority.getStore();
1635
1893
  return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
1636
1894
  },
1895
+ // #933: a conductor task op admitted in onMessage runs inside operationalContext; the role is rechecked on every ask.
1896
+ operational: by => { const scope = operationalContext.getStore(); if (!scope || scope.actor !== by) return false; scope.current(); return true; },
1637
1897
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1638
1898
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1639
1899
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
@@ -1645,6 +1905,8 @@ export async function startDaemon(opts: DaemonOptions) {
1645
1905
  recordPlanning: change => event({ type: "planning", ...change }),
1646
1906
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1647
1907
  escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
1908
+ groomFyiWindowMin: () => config.tasks.groom_fyi_window_min ?? GROOM_FYI_WINDOW_SETTING.defaultValue,
1909
+ ...taskOperationalReaders(() => config), // #489 H2
1648
1910
  profile: profileName(config.profile),
1649
1911
  profileData: resolvedProfile,
1650
1912
  piiPaths: config.pii?.paths,
@@ -1695,11 +1957,15 @@ export async function startDaemon(opts: DaemonOptions) {
1695
1957
  ...(config.memory.enabled ? { memory } : {}),
1696
1958
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1697
1959
  memoryCheck: checkMemory,
1698
- ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1960
+ briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1699
1961
  memoryStore ? taskMemoryItems : undefined,
1700
1962
  (peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
1701
1963
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
1702
- memoryStore ? taskIntentBrief : undefined) } : {}),
1964
+ memoryStore ? taskIntentBrief : undefined,
1965
+ {
1966
+ allowed: corpusBriefAllowed, policyVersion: corpusPolicyVersion,
1967
+ read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
1968
+ }),
1703
1969
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1704
1970
  checkMemory();
1705
1971
  const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
@@ -1712,7 +1978,8 @@ export async function startDaemon(opts: DaemonOptions) {
1712
1978
  ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
1713
1979
  notify,
1714
1980
  recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
1715
- ...(checksAllowed ? { check: (cls: string, task?: import("./board.ts").Task) => checkCommands[task?.profileClass ?? cls] ?? checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
1981
+ ...(checksAllowed ? { check: (cls: string, task?: import("./board.ts").Task) => checkCommands[task?.profileClass ?? cls] ?? checkCommands[cls],
1982
+ checkKey: (cls: string, task?: import("./board.ts").Task) => { const own = task?.profileClass ?? cls; return checkCommands[own] !== undefined ? own : checkCommands[cls] !== undefined ? cls : undefined; }, runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
1716
1983
  // What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
1717
1984
  // Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
1718
1985
  share: (by, line) => {
@@ -1730,15 +1997,20 @@ export async function startDaemon(opts: DaemonOptions) {
1730
1997
  }
1731
1998
  },
1732
1999
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
2000
+ // #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
2001
+ intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
2002
+ partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
1733
2003
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
1734
- piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
2004
+ piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
1735
2005
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1736
2006
  quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
1737
2007
  review: config.review,
1738
2008
  sweep: taskSweepConfig(config.task_sweep),
1739
2009
  sweepHeld: () => stopping || recoveryActive(),
2010
+ submodules: () => factsRepo ? submodulePaths(factsRepo) : [], // #798 D1: read from .gitmodules, no git call
1740
2011
  ciWatch: opts.ciWatch,
1741
2012
  autoPull: () => config.tasks.auto_pull === true,
2013
+ queueBackpressure: queuePolicy,
1742
2014
  cancellationHeld: () => stopping || recoveryActive(),
1743
2015
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1744
2016
  offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer?.pullOnly === true; },
@@ -2025,6 +2297,42 @@ export async function startDaemon(opts: DaemonOptions) {
2025
2297
  };
2026
2298
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
2027
2299
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
2300
+ // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2301
+ const doings = new Map<PeerId, DoingTracker>();
2302
+ const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
2303
+ if (kind === "reset") { doings.delete(peer); claudeDoing.delete(peer); return; }
2304
+ let tracker = doings.get(peer);
2305
+ if (!tracker) doings.set(peer, tracker = new DoingTracker());
2306
+ // Private when learned or observed in a PII context or refused by the public metadata screen; a throw is private too.
2307
+ tracker.observe(kind, text, value => learnedPrivate || contextPii(peer) || !tasks.publicMetadataText(value));
2308
+ };
2309
+ /** Shown only for an attached peer; a stub while it owns or reviews a PII task or is in a private turn. */
2310
+ const doingRows = (ids: string[]): Record<string, PeerDoing> => {
2311
+ const live = ids.filter(id => doings.has(id) && bus.stateOf(id) !== "offline");
2312
+ if (!live.length) return {};
2313
+ const pii = board.list().filter(t => !isClosedTaskState(t.state) && tasks.isPii(t));
2314
+ return Object.fromEntries(live.flatMap(id => {
2315
+ const view = doings.get(id)!.view(!!turns.get(id)?.private || pii.some(t => t.owner === id || t.reviewer === id));
2316
+ return view ? [[id, view]] : [];
2317
+ }));
2318
+ };
2319
+ // A Claude session's title, read from its transcript where the last read stopped; its current item from PreToolUse input.
2320
+ const claudeDoing = new Map<PeerId, { session: string; cursor: TitleCursor; todos: ClaudeTodoSource }>();
2321
+ const observeClaudeDoing = (peer: PeerId, session: string, transcript: string | undefined, tool: string, input: Record<string, unknown>) => {
2322
+ let source = claudeDoing.get(peer);
2323
+ if (source?.session !== session) {
2324
+ observeDoing(peer, "reset");
2325
+ claudeDoing.set(peer, source = { session, cursor: { path: "", offset: 0, skip: false }, todos: new ClaudeTodoSource() });
2326
+ }
2327
+ if (transcript) {
2328
+ // ponytail: one bounded read per hook call, so a long transcript first seen mid-session catches up over several calls.
2329
+ if (source.cursor.path !== transcript) source.cursor = { path: transcript, offset: 0, skip: false };
2330
+ const title = readAiTitle(source.cursor);
2331
+ if (title !== undefined) observeDoing(peer, "title", title);
2332
+ }
2333
+ const item = tool ? source.todos.item(tool, input, subject => contextPii(peer) || !tasks.publicMetadataText(subject)) : undefined;
2334
+ if (item !== undefined) observeDoing(peer, "todo", item?.text, item?.private === true);
2335
+ };
2028
2336
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
2029
2337
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
2030
2338
  if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
@@ -2166,11 +2474,13 @@ export async function startDaemon(opts: DaemonOptions) {
2166
2474
  };
2167
2475
  const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
2168
2476
  bus.onDeliver = (peer, originals) => {
2477
+ if (peer.includes("@")) return;
2169
2478
  pendingDeliveryTask.delete(peer);
2170
2479
  const task = deliveryTask(originals);
2171
2480
  if (task !== undefined) pendingDeliveryTask.set(peer, task);
2172
2481
  };
2173
2482
  bus.onDelivered = (peer, originals) => {
2483
+ if (peer.includes("@")) return;
2174
2484
  pendingDeliveryTask.delete(peer);
2175
2485
  if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
2176
2486
  const turn = turns.get(peer);
@@ -2201,8 +2511,9 @@ export async function startDaemon(opts: DaemonOptions) {
2201
2511
  if (row && tasks.isPii(row)) return;
2202
2512
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
2203
2513
  }) : undefined;
2204
- bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
2514
+ bus.onMemoryDelivery = (peer, id, out, field) => { if (!peer.includes("@") && !out.some(env => env.from.includes("@") || env.to?.some(id => id.includes("@")) || env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
2205
2515
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
2516
+ if (peer.includes("@") || envs.some(env => env.from.includes("@") || env.to?.some(id => id.includes("@")))) return;
2206
2517
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
2207
2518
  memoryObservations?.cite(peer, text, id, envs);
2208
2519
  };
@@ -2253,9 +2564,15 @@ export async function startDaemon(opts: DaemonOptions) {
2253
2564
  executionOwners.delete(peer); executionRoots.delete(peer);
2254
2565
  worktreeFacts.delete(peer); worktreeProjections.delete(peer);
2255
2566
  }
2256
- function currentExecutionOwner(peer: string) {
2567
+ function currentExecutionOwner(peer: string, fresh = false) {
2257
2568
  const bound = executionOwners.get(peer);
2258
- if (bound?.socket) {
2569
+ if (bound && isExternalInstance(peer)) {
2570
+ const launch = externalLaunches.get(peer), receipt = launch && managedAcpLaunch(bound.owner, launch.options);
2571
+ const evidence = externalEvidence(peer, fresh), current = evidence?.binding, workspace = evidence?.workspace;
2572
+ if (!launch || workspace !== launch.workspace || launch.owner !== bound.owner || bus.peers.get(peer) !== bound.owner || !receipt || receipt.generation !== launch.generation || receipt.cwd !== bound.cwd || !current || !sameExternalInstanceBinding(launch.binding, current)) {
2573
+ revokeTerminalExecution(peer); return undefined;
2574
+ }
2575
+ } else if (bound?.socket) {
2259
2576
  const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2260
2577
  if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2261
2578
  revokeTerminalExecution(peer);
@@ -2265,7 +2582,7 @@ export async function startDaemon(opts: DaemonOptions) {
2265
2582
  // an admission/effect. Reuse only within synchronous code, never across an await.
2266
2583
  if (!verifiedTerminalRootsThisTurn.has(bound)) {
2267
2584
  try {
2268
- if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2585
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2269
2586
  } catch { revokeTerminalExecution(peer); return undefined; }
2270
2587
  verifiedTerminalRootsThisTurn.add(bound);
2271
2588
  queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
@@ -2276,13 +2593,23 @@ export async function startDaemon(opts: DaemonOptions) {
2276
2593
  try {
2277
2594
  if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
2278
2595
  if (!verifiedTerminalRootsThisTurn.has(bound)) {
2279
- if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2596
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2280
2597
  verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2281
2598
  }
2282
2599
  } catch { revokeTerminalExecution(peer); return undefined; }
2283
2600
  }
2284
2601
  return bound;
2285
2602
  }
2603
+ /** Last verified roots for display only; no filesystem/Git probe and never owner-admission authority. */
2604
+ function ownerIsolationProjection(): Readonly<Record<string, boolean>> {
2605
+ return Object.fromEntries(extraIds.map(peer => {
2606
+ const bound = executionOwners.get(peer), owner = bus.peers.get(peer);
2607
+ if (!bound || !owner || bound.owner !== owner || owner.state === "offline" || bound.cwd === opts.cwd || bound.cwd !== executionRoots.get(peer)) return [peer, false];
2608
+ if (bound.socket && (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner)
2609
+ || bound.owner.sessionGeneration !== bound.generation || !bound.owner.owns(bound.socket))) return [peer, false];
2610
+ return [peer, true];
2611
+ }));
2612
+ }
2286
2613
  function ownerWriteRoot(peer: string): string | undefined {
2287
2614
  if (declarations[peer]) {
2288
2615
  const bound = currentExecutionOwner(peer);
@@ -2290,10 +2617,19 @@ export async function startDaemon(opts: DaemonOptions) {
2290
2617
  }
2291
2618
  return executionRoot(peer);
2292
2619
  }
2620
+ function currentExternalTools(client: Client, fresh = false): boolean {
2621
+ const peer = client.peer, bound = client.externalOwner;
2622
+ if (!peer || !bound || stopping || recoveryActive() || !membershipAllowed(peer)) return false;
2623
+ const launch = externalLaunches.get(peer), current = currentExecutionOwner(peer, fresh);
2624
+ if (launch && current && launch.owner === bound.owner && current.owner === bound.owner && launch.launchId === bound.launchId && launch.generation && bound.generation === undefined) bound.generation = launch.generation;
2625
+ return !!launch && !!current && launch.owner === bound.owner && current.owner === bound.owner && launch.launchId === bound.launchId && launch.generation === bound.generation;
2626
+ }
2293
2627
  function implementerChatTask(peer: string): number | undefined {
2294
2628
  const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
2295
2629
  return owned.length === 1 ? owned[0]!.id : undefined;
2296
2630
  }
2631
+ type ImplementerFailureEntry = { launchId: string; generation: string; signal: AbortSignal; trace: ClaudeFailureTrace; probeCount: number; permissionAttempt: number };
2632
+ const implementerFailures = new WeakMap<PeerAdapter, ImplementerFailureEntry>();
2297
2633
  function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
2298
2634
  const bound = c.toolsOwner, peer = c.peer;
2299
2635
  if (!bound || !peer || c.role !== "tools" || stopping || recoveryActive() || !membershipAllowed(peer) || contextPii(peer) || definitionOf(peer)?.start.daemonPort !== "claude-implementer" || bus.peers.get(peer) !== bound.owner) return undefined;
@@ -2311,6 +2647,10 @@ export async function startDaemon(opts: DaemonOptions) {
2311
2647
  const binding = c.toolsOwner, launch = currentImplementerTools(c);
2312
2648
  return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2313
2649
  }
2650
+ function currentImplementerFailure(c: Client, launch = currentImplementerTools(c)): ImplementerFailureEntry | undefined {
2651
+ const owner = c.toolsOwner?.owner, entry = owner && implementerFailures.get(owner);
2652
+ return launch && entry && entry.launchId === launch.launchId && entry.generation === launch.generation && entry.signal === launch.signal && !entry.signal.aborted ? entry : undefined;
2653
+ }
2314
2654
  const shellProjectRepository = githubOriginRepository(opts.cwd);
2315
2655
  const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
2316
2656
  const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
@@ -2318,7 +2658,7 @@ export async function startDaemon(opts: DaemonOptions) {
2318
2658
  : undefined;
2319
2659
  let implementerShellStart: Promise<HostShellWrapper> | undefined;
2320
2660
  const implementerShellProfiles = new WeakMap<object, { key: string; sandbox: { profile: string; readOnlyData: boolean } }>();
2321
- async function automaticImplementerShell(c: Client, tool: string, original: Record<string, unknown>, current: () => boolean): Promise<{ command: string; timeout: number; description: string } | undefined> {
2661
+ async function automaticImplementerShell(c: Client, tool: string, original: Record<string, unknown>, current: () => boolean, failure?: { entry: ImplementerFailureEntry; attempt: number }): Promise<{ command: string; timeout: number; description: string } | undefined> {
2322
2662
  const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
2323
2663
  if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
2324
2664
  if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
@@ -2387,7 +2727,8 @@ export async function startDaemon(opts: DaemonOptions) {
2387
2727
  const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
2388
2728
  const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
2389
2729
  env: taskProof?.env ?? (git ? Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => entry[1] !== undefined)) : env),
2390
- signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(), ...(sandbox ? { sandbox } : {}) });
2730
+ signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(),
2731
+ onRefused: () => { if (failure && failure.entry.permissionAttempt === failure.attempt) failure.entry.trace.record("wrapper_refused"); }, ...(sandbox ? { sandbox } : {}) });
2391
2732
  if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
2392
2733
  return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
2393
2734
  }
@@ -2573,6 +2914,11 @@ export async function startDaemon(opts: DaemonOptions) {
2573
2914
  bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
2574
2915
  } catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2575
2916
  }, opts.bandsEveryMs ?? 300_000),
2917
+ // #481 S7: the scheduled security-scan baseline. The tick only asks whether a scan is due; the configured interval, the holds and the lifecycle mode decide inside.
2918
+ setInterval(() => {
2919
+ if (stopping || recoveryActive() || opts.bandsHeld?.()) return;
2920
+ void tasks.runSecurityScan().catch(error => log(`sdlc security scan failed: ${sanitize((error as Error).message).slice(0, 160)}`));
2921
+ }, opts.securityScanEveryMs ?? 3_600_000),
2576
2922
  setInterval(() => {
2577
2923
  if (!ready || !memoryBinding) return;
2578
2924
  const before = memoryProblem;
@@ -2613,6 +2959,54 @@ export async function startDaemon(opts: DaemonOptions) {
2613
2959
  };
2614
2960
  rescheduleSweep();
2615
2961
  startupCleanup.push(() => clearInterval(sweepTimer));
2962
+ // #512a: the schedules timer is its own interval, started whenever `schedules` is configured, whatever `task_sweep.enabled` says, and re-armed on reload.
2963
+ // It runs no task sweep. Held while stopping or recovering (inside `proposeFromSchedules`), where a due schedule counts the skip and coalesces.
2964
+ const schedulesStore = new ScheduleStore(join(opts.stateDir, "hub.db"));
2965
+ startupCleanup.push(() => schedulesStore.close());
2966
+ const folderStore = new FolderStore(join(opts.stateDir, "hub.db"));
2967
+ startupCleanup.push(() => folderStore.close());
2968
+ let schedulesTimer: ReturnType<typeof setInterval> | undefined;
2969
+ const scheduleNow = () => opts.schedulesNow?.() ?? Date.now();
2970
+ /** The pass in flight, so a stop can wait for it before the board and the store close. */
2971
+ let schedulesPass: Promise<void> | undefined;
2972
+ /** #335: `ahub schedule run` calls in flight. They are not the timer's pass, but `stopOnce` waits for them just the same. */
2973
+ const manualSchedulePasses = new Set<Promise<void>>();
2974
+ const runSchedules = (): Promise<void> => {
2975
+ if (stopping || schedulesPass) return Promise.resolve();
2976
+ schedulesPass = runSchedulesOnce().finally(() => { schedulesPass = undefined; });
2977
+ return schedulesPass;
2978
+ };
2979
+ const runSchedulesOnce = async () => {
2980
+ try {
2981
+ for (const run of await tasks.proposeFromSchedules(schedulesStore, config.schedules, scheduleNow(), () => opts.schedulesHeld?.() === true)) {
2982
+ if (run.outcome === "ran") log(`schedule ${run.id}: proposed task #${run.task}`);
2983
+ else if (run.outcome === "failed") log(`schedule ${run.id}: could not propose; see ahub status`);
2984
+ }
2985
+ if (config.folder_triggers.length) {
2986
+ const policy: FolderPolicy = { cwd: opts.cwd, dataPaths: config.data?.readonly ?? [], piiPaths: config.pii?.paths ?? [], deny: config.local.deny };
2987
+ // Registration is the same two steps `ahub source add` takes (`registerScanned`): one decision about privacy, then the registry. The scan kept no bytes, so the file
2988
+ // is read again there and refused if it no longer hashes to what the scan saw. A file under pii.paths or whose text matches a PII pattern is a private source.
2989
+ const register = (file: ScannedFile) => registerScanned(file, { registry: sourceRegistry, cwd: opts.cwd, piiPaths: config.pii?.paths ?? [], piiPatterns: () => activeRouting().signals.pii_patterns,
2990
+ project: (extension, data) => projections.projectBytes(join(opts.cwd, `source${extension}`), data)?.text });
2991
+ for (const run of await tasks.runFolderTriggers(folderStore, config.folder_triggers, entry => scanFolder(entry, policy, (real, size, mtime) => folderStore.known(entry.id, real, size, mtime)), register, scheduleNow(), () => opts.schedulesHeld?.() === true)) {
2992
+ if (run.outcome === "ran") log(`folder trigger ${run.id}: proposed ${run.tasks.map(id => `task #${id}`).join(", ")}`);
2993
+ else if (run.outcome === "failed" || run.outcome === "refused") log(`folder trigger ${run.id}: ${run.outcome}: ${sanitize(run.reason ?? "").slice(0, 160)}`);
2994
+ }
2995
+ }
2996
+ if (!stopping) writeStatus(); // a pass that ends during shutdown must not republish the status file shutdown is about to remove
2997
+ } catch (error) { log(`schedules pass failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2998
+ };
2999
+ const rescheduleSchedules = () => {
3000
+ if (schedulesTimer) clearInterval(schedulesTimer);
3001
+ schedulesTimer = undefined;
3002
+ if (!config.folder_triggers.length) { try { folderStore.prune([]); } catch { /* the store is closed while stopping */ } }
3003
+ if (!config.schedules.length && !config.folder_triggers.length) { try { schedulesStore.prune([]); } catch { /* the store is closed while stopping */ } return; }
3004
+ if (!config.schedules.length) { try { schedulesStore.prune([]); } catch { /* the store is closed while stopping */ } }
3005
+ schedulesTimer = setInterval(() => { void runSchedules(); }, opts.schedulesEveryMs ?? 60_000);
3006
+ schedulesTimer.unref?.();
3007
+ };
3008
+ rescheduleSchedules();
3009
+ startupCleanup.push(() => clearInterval(schedulesTimer));
2616
3010
  for (const i of intervals) i.unref?.();
2617
3011
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
2618
3012
 
@@ -2634,6 +3028,7 @@ export async function startDaemon(opts: DaemonOptions) {
2634
3028
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
2635
3029
  const [by, op, input] = args;
2636
3030
  if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
3031
+ const sourceCurrent = op === "hub_source_read" ? sourceReadCurrent(by, args[3] ?? false, args[4] ?? false) : undefined;
2637
3032
  const nativeTurn = turns.get(by);
2638
3033
  const effectful = ownerToolEffect(op, input);
2639
3034
  if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
@@ -2647,6 +3042,7 @@ export async function startDaemon(opts: DaemonOptions) {
2647
3042
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2648
3043
  const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
2649
3044
  const text = await taskOpBody(...args);
3045
+ if (sourceCurrent && !sourceCurrent()) throw new PreEffectToolRefusal("source read unavailable");
2650
3046
  if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2651
3047
  const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2652
3048
  if (conducted) {
@@ -2654,13 +3050,13 @@ export async function startDaemon(opts: DaemonOptions) {
2654
3050
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
2655
3051
  auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
2656
3052
  }
2657
- const hint = args[3] ? ownHeldHint(by) : "";
3053
+ const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
2658
3054
  return hint ? `${text}\n\n${hint}` : text;
2659
3055
  } catch (error) {
2660
3056
  if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
2661
3057
  if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2662
3058
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2663
- const hint = args[3] ? ownHeldHint(by) : "";
3059
+ const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
2664
3060
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
2665
3061
  throw error;
2666
3062
  } finally {
@@ -2680,6 +3076,16 @@ export async function startDaemon(opts: DaemonOptions) {
2680
3076
  if (!id || !PEER_ID.test(id)) throw new PreEffectToolRefusal(`${name} must be a peer id, not ${JSON.stringify(v).slice(0, 60)}`);
2681
3077
  return id;
2682
3078
  };
3079
+ function sourceReadCurrent(by: PeerId, inProcess: boolean, piiTurn: boolean): () => boolean {
3080
+ const owner = bus.peers.get(by), generation = connectionGeneration(by);
3081
+ const authority = childToolAuthority.getStore();
3082
+ const routing = activeRouting(), policyKey = JSON.stringify({ signals: routing.signals, constraints: routing.constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: routing.local.fixed_model });
3083
+ return () => !stopping && bus.peers.get(by) === owner && connectionGeneration(by) === generation
3084
+ && (!authority || authority.by === by && authority.current())
3085
+ && (!inProcess || !piiTurn || holdsPii(by))
3086
+ && policyKey === JSON.stringify({ signals: activeRouting().signals, constraints: activeRouting().constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: activeRouting().local.fixed_model });
3087
+ }
3088
+ const sourceRegistry = new SourceRegistry(opts.stateDir);
2683
3089
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
2684
3090
  if (BOUNDED_TASK_TOOLS.has(op)) {
2685
3091
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
@@ -2720,6 +3126,29 @@ export async function startDaemon(opts: DaemonOptions) {
2720
3126
  need("propose", "propose tasks");
2721
3127
  if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
2722
3128
  }
3129
+ if (op === "hub_source_read") {
3130
+ validateSourceRead(a);
3131
+ const valid = sourceReadCurrent(by, inProcess, piiTurn);
3132
+ return JSON.stringify(await sourceRead(sourceRegistry, a, {
3133
+ valid,
3134
+ canReadPrivate: async () => {
3135
+ const eligible = () => valid() && inProcess && piiTurn && holdsPii(by)
3136
+ && isFixedBuiltinDefinition(definitionOf(by)) && definitionOf(by)?.trust.onPrem === true;
3137
+ if (!eligible()) return false;
3138
+ const campus = await onCampus().catch(() => false);
3139
+ return campus && eligible();
3140
+ },
3141
+ screen: async (text, meta) => {
3142
+ if (meta.origins.some(origin => origin.kind === "file" && matchesPolicy(opts.cwd, origin.origin, config.pii?.paths ?? []))) return false;
3143
+ try { if (looksSecret(text) || activeRouting().signals.pii_patterns.some(pattern => new RegExp(pattern).test(text))) return false; }
3144
+ catch { return false; }
3145
+ if (!tasks.screenOn()) return true;
3146
+ // Reuse the bounded host screen. Oversized/unknown is private, never an unscreened excerpt.
3147
+ const verdict = await (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text).catch(() => undefined);
3148
+ return verdict?.label === "clear";
3149
+ },
3150
+ }));
3151
+ }
2723
3152
  if (MEMORY_READ_TOOLS.has(op)) {
2724
3153
  validateMemoryRead(op, a);
2725
3154
  need("memory_read", "read shared memory");
@@ -2858,10 +3287,11 @@ export async function startDaemon(opts: DaemonOptions) {
2858
3287
  case "hub_task_propose": {
2859
3288
  const t = await tasks.propose(by, a);
2860
3289
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
2861
- if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
3290
+ const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
3291
+ if (t.state === "backlog") return [line(t), repoHint, submodules].filter(Boolean).join("\n");
2862
3292
  const overlap = tasks.overlaps(t, t.owner === by);
2863
3293
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2864
- return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
3294
+ return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
2865
3295
  }
2866
3296
  case "hub_task_accept": {
2867
3297
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2870,7 +3300,7 @@ export async function startDaemon(opts: DaemonOptions) {
2870
3300
  const silent = tasks.silentFor(t.id);
2871
3301
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
2872
3302
  if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
2873
- return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
3303
+ return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
2874
3304
  }
2875
3305
  case "hub_task_cancel": {
2876
3306
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -2933,7 +3363,7 @@ export async function startDaemon(opts: DaemonOptions) {
2933
3363
  if (!release) throw new PreEffectToolRefusal("no such release");
2934
3364
  releaseId = release.id;
2935
3365
  }
2936
- return JSON.stringify(board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length)).map((t) => { const view: Record<string, unknown> = onPrem ? { ...t } : tasks.publicView(t); const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; }));
3366
+ return JSON.stringify(board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length)).map((t) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; }));
2937
3367
  }
2938
3368
  }
2939
3369
  if (op === "task_show" && by !== USER) {
@@ -2947,12 +3377,31 @@ export async function startDaemon(opts: DaemonOptions) {
2947
3377
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2948
3378
  return tasks.publicExplain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
2949
3379
  }
2950
- if (by !== USER) throw new Error(`${op} is a console command`);
3380
+ if (op === "deliverables" && by !== USER) {
3381
+ // #933: an agent's list holds only the rows hub_task_show would show it (conductor or planner any non-backlog task, an owner or reviewer its own), through the same public records.
3382
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
3383
+ const visible = (task: Task) => taskShowAllowed(by, task, config.roles, tasks.isConfiguredPlanner(by));
3384
+ return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true, visible }));
3385
+ }
3386
+ if (op === "task_label_check" && by !== USER) {
3387
+ // #933: an agent labels only a task it owns; the CLI writes the research record after this answer.
3388
+ const task = Number.isSafeInteger(a.id) && a.id > 0 ? board.get(a.id) : undefined;
3389
+ if (!task || task.owner !== by) throw new PreEffectToolRefusal(`task #${Number.isSafeInteger(a.id) ? a.id : "?"} is not yours: an agent labels its own task only`);
3390
+ return JSON.stringify({ task: task.id, owner: by });
3391
+ }
3392
+ if (op === "task_unassign" && by !== USER) {
3393
+ if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer") throw new PreEffectToolRefusal("role must be owner or reviewer");
3394
+ if (a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("revision must be a nonnegative integer");
3395
+ return line(await tasks.unassign(a.id, by, true, a.role, a.revision));
3396
+ }
3397
+ if (by !== USER && !(Object.hasOwn(CONDUCTOR_TASK_OPS, op) && operationalContext.getStore()?.actor === by)) throw new Error(`${op} is a console command`);
2951
3398
  switch (op) {
2952
3399
  case "release_new":
2953
3400
  return JSON.stringify(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order }));
3401
+ case "claim_verdict": // #901: a person's verdict on one declared claim; the reply names the slot and the verdict only, never a claim
3402
+ return JSON.stringify((({ id, history }) => ({ task: id, recorded: history.at(-1)?.claim }))(tasks.recordClaimVerdict(by, a.id, { slot: a.slot, verdict: a.verdict, ...(a.model === undefined ? {} : { model: a.model }), ...(a.modelVerdict === undefined ? {} : { modelVerdict: a.modelVerdict }) })));
2954
3403
  case "github_import":
2955
- return JSON.stringify(await tasks.importGitHub(USER, { repo: a.repo, label: a.label, milestone: a.milestone }));
3404
+ return JSON.stringify(await tasks.importGitHub(by, { repo: a.repo, label: a.label, milestone: a.milestone }));
2956
3405
  case "release_show": {
2957
3406
  const release = a.id !== undefined ? (Number.isSafeInteger(a.id) && a.id > 0 ? board.release(a.id) : undefined) : board.releases().find(release => release.name === a.name);
2958
3407
  if (!release) throw new PreEffectToolRefusal("no such release");
@@ -2968,6 +3417,58 @@ export async function startDaemon(opts: DaemonOptions) {
2968
3417
  case "backlog_pull":
2969
3418
  case "backlog_return":
2970
3419
  return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
3420
+ // #335: the person's schedule commands; #933: the admitted conductor's too. Tasks refuses any other actor.
3421
+ case "schedule_list":
3422
+ if (by !== USER) throw new PreEffectToolRefusal("schedule list is a console command");
3423
+ return JSON.stringify(scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true));
3424
+ case "schedule_run": {
3425
+ // The run is registered with the shutdown drain, so `stopOnce` waits for it, as for a timer pass, before the board and the store close (#335). A run that arrives
3426
+ // once stop has begun is refused earlier still, by the control layer ("hub is stopping"); the test below holds that, so nothing here repeats it.
3427
+ const run = tasks.runScheduleNow(by, schedulesStore, config.schedules, a.id, scheduleNow(), () => opts.schedulesHeld?.() === true);
3428
+ const drained = run.then(() => undefined, () => undefined);
3429
+ manualSchedulePasses.add(drained); void drained.finally(() => manualSchedulePasses.delete(drained));
3430
+ return JSON.stringify(await run);
3431
+ }
3432
+ case "schedule_pause":
3433
+ case "schedule_resume":
3434
+ tasks.setSchedulePaused(by, schedulesStore, config.schedules, a.id, op === "schedule_pause", scheduleNow());
3435
+ return JSON.stringify(scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true).filter(view => view.id === a.id));
3436
+ // #334: natural-language intake. Person only, except approve for the admitted conductor (#933): Tasks refuses any other actor.
3437
+ case "intake": {
3438
+ const result = await tasks.intake(by, { text: a.text, sources: a.sources });
3439
+ return JSON.stringify({ draft: tasks.publicView(result.draft), children: result.children.map(child => tasks.publicView(child)), split: result.split, ...(result.reason ? { reason: result.reason } : {}) });
3440
+ }
3441
+ case "intake_approve":
3442
+ return JSON.stringify((await tasks.approveIntake(by, a.id)).map(task => tasks.publicView(task)));
3443
+ case "intake_discard":
3444
+ return JSON.stringify((await tasks.discardIntake(by, a.id)).map(task => tasks.publicView(task)));
3445
+ // #514b: the person's verdict on a recurring-criterion candidate. Person only (Tasks refuses any other `by`, and the generic gate above already refuses every non-person caller, the configured
3446
+ // conductor included: none of these is in CONDUCTOR_TASK_OPS). Accept writes ONE key, derived from the card's key line, through the settings writer.
3447
+ case "learn_list":
3448
+ return JSON.stringify(tasks.learnCandidates(by, { all: a.all === true }));
3449
+ case "learn_reject":
3450
+ return JSON.stringify(tasks.publicView(await tasks.learnClose(by, a.id, "rejected")));
3451
+ case "learn_accept": {
3452
+ // A card the person already accepted answers with a success that writes nothing: no config, no undo record, no event. A rejected or otherwise closed card falls through and is refused.
3453
+ const decided = tasks.learnAccepted(by, a.id);
3454
+ if (decided) return JSON.stringify({ card: tasks.publicView(board.get(decided.id)!), accepted: { profile: decided.profile, class: decided.class, criterion: decided.criterion, alreadyAccepted: true, file: ".agenthub/config.local.json" } });
3455
+ const plan = tasks.learnAcceptPlan(by, a.id);
3456
+ const stored = storedConfig();
3457
+ if (!stored) throw new PreEffectToolRefusal("the config files do not load; fix them in your editor first: nothing was written");
3458
+ const active = profileName(stored.profile);
3459
+ if (plan.profile !== active) throw new PreEffectToolRefusal(`this card is for the ${plan.profile} profile and the project's profile is ${active}: nothing was written; reject the card, or switch profile first`);
3460
+ // review_checklist replaces as a unit: extend the checklist the profile uses now (the person's, else the shipped one), never start from one line.
3461
+ const current = resolveProfile(stored.profile, stored.profile_overrides).review_checklist ?? [];
3462
+ const next = withChecklistLine(current, plan.line);
3463
+ if ("refused" in next) throw new PreEffectToolRefusal(next.refused);
3464
+ const already = next.list.length === current.length;
3465
+ if (!already) {
3466
+ const written = await settingsSet(LEARN_CHECKLIST.key, next.list, "terminal", undefined, undefined, `ahub learn accept #${plan.id}`, LEARN_CHECKLIST);
3467
+ if (written.ok !== true) throw new PreEffectToolRefusal(String(written.error ?? "the checklist could not be written") + "; the candidate stays open");
3468
+ }
3469
+ const closed = await tasks.learnClose(by, a.id, "accepted");
3470
+ return JSON.stringify({ card: tasks.publicView(closed), accepted: { profile: plan.profile, class: plan.class, criterion: plan.criterion, line: plan.line, tasks: plan.tasks, lines: next.list.length, alreadyPresent: already, file: ".agenthub/config.local.json", applies: "after a restart (profile contracts are bound at startup)", undo: already ? undefined : "ahub settings undo" } });
3471
+ }
2971
3472
  case "review_brief": {
2972
3473
  const task = board.get(Number(a.id));
2973
3474
  return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
@@ -2976,12 +3477,12 @@ export async function startDaemon(opts: DaemonOptions) {
2976
3477
  const task = board.get(Number(a.id));
2977
3478
  if (!task) return JSON.stringify(`no task #${a.id}`);
2978
3479
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2979
- const codeSymbols = tasks.publicView(task).codeSymbols;
3480
+ const publicTask = tasks.publicView(task), codeSymbols = publicTask.codeSymbols;
2980
3481
  const consoleDeliverables = await tasks.deliverableStatus(task);
2981
3482
  const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
2982
3483
  const fresh = board.get(task.id);
2983
3484
  const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
2984
- return JSON.stringify({ ...detail, relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
3485
+ return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2985
3486
  }
2986
3487
  case "deliverables": {
2987
3488
  // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
@@ -3022,7 +3523,7 @@ export async function startDaemon(opts: DaemonOptions) {
3022
3523
  if (workspace !== undefined) {
3023
3524
  const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
3024
3525
  if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
3025
- const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
3526
+ const validated = resolveExecutionCwd(turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
3026
3527
  const bound = executionOwners.get(turn.peer);
3027
3528
  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");
3028
3529
  }
@@ -3115,6 +3616,33 @@ export async function startDaemon(opts: DaemonOptions) {
3115
3616
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
3116
3617
  const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
3117
3618
  let stopping = false;
3619
+ const roomChat = new RoomChat({ bus, cwd: opts.cwd, routing: activeRouting, piiPaths: () => config.pii?.paths ?? [],
3620
+ senderPrivate: peer => peer === USER ? false : bus.peers.has(peer) && !peer.includes("@") ? !!turns.get(peer)?.private || contextPii(peer) : undefined,
3621
+ taskPrivate: id => { const task = board.get(id); return task ? tasks.isPii(task) : undefined; },
3622
+ modelScreenOn: () => tasks.screenOn(),
3623
+ screen: opts.piiScreen ?? (text => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
3624
+ policyVersion: () => configRevision,
3625
+ limits: () => config.limits,
3626
+ notice: value => notify(value.code === "application_capacity" ? `room ${value.room}: application capacity reached (${value.count}); resolve incoming uncertainty with ahub room resolve` : `room ${value.room}: signed frame sequence gap ${value.count} from ${value.sender}`),
3627
+ roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
3628
+ changed: () => { if (ready && !stopping) writeStatus(); },
3629
+ });
3630
+ remotePending = room => roomChat.pendingIds(room);
3631
+ remoteQueueChanged = () => roomChat.queueChanged();
3632
+ const roomCommands = new RoomCommands({ stateDir: opts.stateDir, cwd: opts.cwd,
3633
+ presence: () => [...bus.peers].filter(([id]) => !id.includes("@") && membershipAllowed(id)).map(([id, peer]) => ({ id, state: peer.state })), chat: roomChat,
3634
+ });
3635
+ // Room crypto/state failure disables only this optional outbound service, never local work.
3636
+ void roomCommands.start().catch(() => log("rooms unavailable"));
3637
+ let roomInboxExpiryFailed = false;
3638
+ const roomInboxExpiry = setInterval(() => {
3639
+ if (stopping || recoveryActive() || bus.isRecoveryHeld) return;
3640
+ try { roomCommands.expireInbox(); roomInboxExpiryFailed = false; }
3641
+ catch { if (!roomInboxExpiryFailed) log("room inbox expiry unavailable"); roomInboxExpiryFailed = true; }
3642
+ }, 30_000);
3643
+ roomInboxExpiry.unref?.(); intervals.push(roomInboxExpiry);
3644
+ const roomTickets = new WeakMap<Sock, { id: string; expires: number; preview: RoomPreview }>();
3645
+
3118
3646
  const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
3119
3647
  docs: opts.releaseDocEvidence,
3120
3648
  gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
@@ -3165,9 +3693,11 @@ export async function startDaemon(opts: DaemonOptions) {
3165
3693
  };
3166
3694
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
3167
3695
  const recoveryReady = () => {
3168
- 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" || ("recoveryReady" in peer && peer.recoveryReady !== true))) return false;
3696
+ 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()].filter(peer => !peer.id.includes("@")).some((peer) => peer.state === "busy" || ("recoveryReady" in peer && peer.recoveryReady !== true))) return false;
3169
3697
  if (checkPending.size !== 0) return false;
3170
3698
  if (tasks.autoPullsPending() !== 0) return false; // #785 P1c: an automatic backlog pull mid-admission would write after the commit's digest; its own line keeps the line above exact for scripts/seeds.json
3699
+ if (tasks.queuePressureRechecksPending() !== 0) return false;
3700
+ if (tasks.schedulesPending() !== 0) return false; // #512a: a scheduled run mid-admission (its PII screen is awaited) would write after the commit's digest; the hold fence refuses its write, and this waits for it to end
3171
3701
  if (!recoveryPeerSnapshot) return true;
3172
3702
  const current = recoveryPeers();
3173
3703
  return recoveryPeerSnapshot.every((saved) => {
@@ -3288,7 +3818,14 @@ export async function startDaemon(opts: DaemonOptions) {
3288
3818
  } };
3289
3819
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
3290
3820
  claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
3291
- const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
3821
+ const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...new Set([...bus.peers.keys(), ...allExternalInstanceIds()])].filter(id => !id.includes("@")).map(id => {
3822
+ const peer = bus.peers.get(id);
3823
+ if (isExternalInstance(id) || externalRefusedRows.has(id)) {
3824
+ const binding = externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id), receipt = peer && externalLaunches.get(id) ? managedAcpLaunch(peer, externalLaunches.get(id)!.options) : undefined;
3825
+ const saved = restored?.peers.find(row => row.id === id);
3826
+ return [id, { id, state: peer?.state ?? "offline", queueIds: bus.queueIds(id), ...(binding ? { externalInstance: binding } : {}), ...(receipt?.sessionId ? { sessionId: receipt.sessionId } : externalSessions.has(id) ? { sessionId: externalSessions.get(id) } : saved?.sessionId ? { sessionId: saved.sessionId } : {}) }];
3827
+ }
3828
+ if (!peer) throw new Error("recovery peer disappeared");
3292
3829
  const metadata = peer.recoveryMetadata?.() ?? {};
3293
3830
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
3294
3831
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
@@ -3310,7 +3847,7 @@ export async function startDaemon(opts: DaemonOptions) {
3310
3847
  return row;
3311
3848
  });
3312
3849
  const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
3313
- return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
3850
+ return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
3314
3851
  };
3315
3852
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
3316
3853
  const integrityAs = (expected: unknown) => {
@@ -3358,11 +3895,11 @@ export async function startDaemon(opts: DaemonOptions) {
3358
3895
  };
3359
3896
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
3360
3897
  const reservations = board.statusReservations();
3361
- const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
3898
+ const ids = new Set([...bus.knownPeers(), ...extraIds, ...allExternalInstanceIds(), ...reservations].filter(id => !id.includes("@")));
3362
3899
  for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
3363
3900
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
3364
3901
  for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
3365
- 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));
3902
+ return [...ids].filter(peer => !peer.includes("@") && (isExternalInstance(peer) || externalRefusedRows.has(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)));
3366
3903
  };
3367
3904
  let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
3368
3905
  const checkoutBranch = () => {
@@ -3372,20 +3909,25 @@ export async function startDaemon(opts: DaemonOptions) {
3372
3909
  }
3373
3910
  return checkoutInTick.branch;
3374
3911
  };
3375
- const status = (includeAttentionItems = false, includeAttentionInput = false) => {
3912
+ const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
3376
3913
  const taskNeeds = board.statusTasks();
3377
- const taskStalls = tasks.stalled();
3914
+ const ownerIsolation = ownerIsolationProjection();
3915
+ const taskStalls = tasks.stalled({ ownerIsolation });
3378
3916
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
3379
3917
  const peerIds = byKind(statusPeerIds(taskNeeds));
3918
+ const queueBackpressure = tasks.queuePressureStatus(peerIds);
3380
3919
  const view = {
3920
+ ...(queueBackpressure ? { queueBackpressure } : {}),
3381
3921
  ...(crashReport.length ? { crash: crashReport } : {}),
3382
3922
  projectId,
3383
3923
  instanceId,
3924
+ rooms: roomCommands.statusView(),
3384
3925
  version: VERSION,
3385
3926
  pluginBundle: SERVER_JS,
3386
3927
  pendingRestart: allPendingRestart(),
3387
3928
  retainedConfig,
3388
3929
  ...(agentDrift.length ? { agentDrift } : {}),
3930
+ ...(config.schedules.length ? { schedules: scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true) } : {}),
3389
3931
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3390
3932
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3391
3933
  memoryProfile: memoryProfileStatus(includeAttentionItems),
@@ -3399,13 +3941,21 @@ export async function startDaemon(opts: DaemonOptions) {
3399
3941
  features: DAEMON_FEATURES,
3400
3942
  configuredAdapters: configuredIds,
3401
3943
  ...(externalAdapters.length ? { externalAdapters } : {}),
3944
+ ...(allExternalInstanceIds().length ? { externalInstanceCatalog: externalInstanceCatalog({ schema: 1, projectRoot: opts.cwd, epoch: instanceId,
3945
+ fingerprint: digest(allExternalInstanceIds().map(id => [id, externalBindings.get(id) ?? null])), instances: allExternalInstanceIds().map(id => {
3946
+ const refused = externalRefusedRows.get(id);
3947
+ if (refused) return { id, kind: refused.kind, displayName: refused.kind, roles: [], form: "headless", readOnly: false, permissionState: "unknown", availability: "refused", refusalCode: refused.refusalCode };
3948
+ const bound = retainedExternalBinding(id), current = currentExecutionOwner(id), owner = bus.peers.get(id);
3949
+ return { id, kind: declarations[id]!.kind, displayName: definitionOf(id)!.displayName, roles: config.roles[id] ?? [], form: "headless", readOnly: readOnlyPeer(id), permissionState: owner?.permissionModeState ?? "unknown",
3950
+ availability: !bound ? "refused" : current ? "ready" : "offline", ...(!bound ? { refusalCode: "binding-changed" } : !owner ? { refusalCode: "not-started" } : !current ? { refusalCode: "ownership-unverified" } : {}) };
3951
+ }) }) } : {}),
3402
3952
  ...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
3403
3953
  startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
3404
3954
  readOnlyPeers: extraIds.filter(readOnlyPeer),
3405
3955
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
3406
3956
  activeInstances: extraIds,
3407
- ownerIsolation: Object.fromEntries(extraIds.map(id => [id, !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd])),
3408
- ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => definitionOf(id)?.transport === "claude-channel" && !ownerWriteRoot(id)).map(id => [id, "terminal execution root not verified; owner admission refused"])),
3957
+ ownerIsolation,
3958
+ ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => !ownerIsolation[id]).map(id => [id, declarations[id]?.worktree ? definitionOf(id)?.transport === "claude-channel" ? "terminal execution root not verified; owner admission refused" : "execution root not verified" : "no declared write worktree"])),
3409
3959
  stopping,
3410
3960
  pid: process.pid,
3411
3961
  cwd: opts.cwd,
@@ -3413,12 +3963,12 @@ export async function startDaemon(opts: DaemonOptions) {
3413
3963
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3414
3964
  codexProxyPort: opts.codexProxyPort,
3415
3965
  peers: Object.fromEntries(
3416
- 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))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(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), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(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 ?? [] } : {}) }]; }),
3966
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const pressure = queueBackpressure?.peers[id]; const oldestQueuedAt = pressure ? pressure.oldestQueuedAt : summary.oldestQueuedAt; const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; return [id, { state: bus.stateOf(id), ...(includeAttentionItems && isExternalInstance(id) && !externalRefusedRows.has(id) && retainedExternalBinding(id) ? { externalInstance: retainedExternalBinding(id) } : {}), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: ownerIsolation[id] === true, ...(ownerIsolation[id] !== true ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(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), ...(typeof oldestQueuedAt === "number" ? { oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(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 ?? [] } : {}) }]; }),
3417
3967
  ),
3418
3968
  taskWip: tasks.wipBreakdown(peerIds),
3419
3969
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
3420
3970
  actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
3421
- roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
3971
+ roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...roleVisibility(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
3422
3972
  planner: { peers: plannerPeers(), source: plannerSource },
3423
3973
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
3424
3974
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -3429,6 +3979,8 @@ export async function startDaemon(opts: DaemonOptions) {
3429
3979
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
3430
3980
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
3431
3981
  };
3982
+ // #878: live readers only (the status reply, the dashboard); writeStatus never asks, so status.json never holds it.
3983
+ if (includeDoing) for (const [id, doing] of Object.entries(doingRows(peerIds))) Object.assign(view.peers[id]!, { doing });
3432
3984
  const peerNeeds: Record<string, NonNullable<NeedsInput["peers"]>[string]> = Object.fromEntries(Object.entries(view.peers).map(([peer, row]) => [peer, { state: row.state, attached: row.attached, needsReview: row.needsReview, heldBy: row.heldBy }]));
3433
3985
  // Lazy legacy peer lists need not contain a persisted owner that is now offline.
3434
3986
  for (const task of taskNeeds) if (task.owner && task.owner !== USER && task.owner !== HUB && !Object.hasOwn(peerNeeds, task.owner)) {
@@ -3498,8 +4050,10 @@ export async function startDaemon(opts: DaemonOptions) {
3498
4050
  }
3499
4051
  const noticedHolds = new Set<string>();
3500
4052
  let reportingQueues = false;
4053
+ let queuePressureStatusQueued = false;
3501
4054
  bus.onQueues = () => {
3502
4055
  if (stopping || settlingSuperseded || reportingQueues) return;
4056
+ remoteQueueChanged();
3503
4057
  reportingQueues = true;
3504
4058
  try {
3505
4059
  settleSupersededDeliveries();
@@ -3511,6 +4065,18 @@ export async function startDaemon(opts: DaemonOptions) {
3511
4065
  }
3512
4066
  writeStatus();
3513
4067
  } finally { reportingQueues = false; }
4068
+ if (ready) {
4069
+ tasks.scheduleQueuePressureRecheck();
4070
+ const policy = queuePolicy();
4071
+ if ((policy.max_queued > 0 || policy.max_oldest_s > 0) && !queuePressureStatusQueued) {
4072
+ queuePressureStatusQueued = true;
4073
+ // withdraw() notifies before its synchronous checkpoint. Refresh only after it commits.
4074
+ queueMicrotask(() => {
4075
+ queuePressureStatusQueued = false;
4076
+ if (!stopping) try { writeStatus(); } catch { log("queue pressure status unavailable"); }
4077
+ });
4078
+ }
4079
+ }
3514
4080
  };
3515
4081
  // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
3516
4082
  settleSupersededDeliveries();
@@ -3694,7 +4260,7 @@ export async function startDaemon(opts: DaemonOptions) {
3694
4260
  if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3695
4261
  piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
3696
4262
  }
3697
- const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
4263
+ const peers = [...bus.peers.values()].filter((p) => p.state !== "offline" && !p.id.includes("@")).map((p) => {
3698
4264
  let meta: Record<string, unknown> = {};
3699
4265
  try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
3700
4266
  return { peer: p.id, meta };
@@ -3714,6 +4280,7 @@ export async function startDaemon(opts: DaemonOptions) {
3714
4280
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
3715
4281
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
3716
4282
  for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
4283
+ if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`); continue; }
3717
4284
  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; }
3718
4285
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
3719
4286
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
@@ -3750,8 +4317,11 @@ export async function startDaemon(opts: DaemonOptions) {
3750
4317
  uiEvents.push({ seq: ++uiSequence, event: e });
3751
4318
  if (uiEvents.length > 200) uiEvents.shift();
3752
4319
  if (e.t === "state") {
4320
+ roomCommands.changed();
3753
4321
  log(`state ${e.peer} -> ${e.state}`);
3754
4322
  event({ type: "state", peer: e.peer, state: e.state });
4323
+ if (ready) tasks.scheduleQueuePressureRecheck();
4324
+ if (e.peer.includes("@")) { if (!stopping) writeStatus(); return; }
3755
4325
  // #248: the add-ons it works with, once per attach (names and versions only).
3756
4326
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
3757
4327
  // still did its work under that attachment.
@@ -3859,7 +4429,11 @@ export async function startDaemon(opts: DaemonOptions) {
3859
4429
  }
3860
4430
  }
3861
4431
  snapshotMs = (snapshotMs ?? 0) + end.ms;
3862
- 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}`); }
4432
+ // #798 D2: a submodule's own edits never move the superproject's gitlink, so the diff above cannot see them. Name the dirty ones as unobserved (never as changed:
4433
+ // undo would try to restore them). Only for a turn whose trees are known; a read git cannot answer, or the budget cut short, records nothing, which stays unknown.
4434
+ let unobserved: string[] | undefined;
4435
+ if (open.tree && end.tree && end.repo) try { unobserved = dirtySubmodules(end.repo, end.budget); } catch (error) { snapshotFailure(`the submodules of ${open.id}`, error); }
4436
+ try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20), unobserved); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
3863
4437
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
3864
4438
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
3865
4439
  }
@@ -3958,7 +4532,10 @@ export async function startDaemon(opts: DaemonOptions) {
3958
4532
  const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
3959
4533
  type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
3960
4534
  async function onPermission(req: PermissionRequest, signal?: AbortSignal): Promise<string | undefined> {
4535
+ const external = isExternalInstance(req.peer) ? currentExecutionOwner(req.peer, true) : undefined;
4536
+ if (isExternalInstance(req.peer) && !external) return undefined;
3961
4537
  const result = await requestPermission(req, signal);
4538
+ if (external && (currentExecutionOwner(req.peer, true) !== external || !membershipAllowed(req.peer))) return undefined;
3962
4539
  return result.outcome === "answered" ? result.optionId : undefined;
3963
4540
  }
3964
4541
  /** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
@@ -3983,7 +4560,7 @@ export async function startDaemon(opts: DaemonOptions) {
3983
4560
  }
3984
4561
  return !gate.denyOnly && picked.optionId === "allow";
3985
4562
  }
3986
- async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false): Promise<PermissionResult> {
4563
+ async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false, lifecycle?: { registered(id: string): void; expired(id: string): void }): Promise<PermissionResult> {
3987
4564
  const requestEpoch = permissionAnswerEpoch(req.peer);
3988
4565
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
3989
4566
  try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
@@ -4021,6 +4598,7 @@ export async function startDaemon(opts: DaemonOptions) {
4021
4598
  signal?.removeEventListener("abort", aborted);
4022
4599
  permissions.delete(id); // withdrawal happens before any observer, resolution or late grant
4023
4600
  writeStatus(); // token-free readers must observe withdrawal before settlement callbacks
4601
+ if (!option && reason === "expired") { try { lifecycle?.expired(id); } catch { /* Host diagnostics never change approval settlement. */ } }
4024
4602
  const outcome = option ? "answered" : reason;
4025
4603
  if (surface) permissionAnswerEpochs.set(req.peer, permissionAnswerEpoch(req.peer) + 1);
4026
4604
  observed(surface ? "person" : outcome === "expired" ? "expired" : "aborted");
@@ -4034,6 +4612,7 @@ export async function startDaemon(opts: DaemonOptions) {
4034
4612
  return true;
4035
4613
  };
4036
4614
  permissions.set(id, { push, done, peer: req.peer, ...(publicTool ? { tool: publicTool } : {}), createdAt, expiresAt });
4615
+ try { lifecycle?.registered(id); } catch { /* Host diagnostics never change approval admission. */ }
4037
4616
  writeStatus(); // an approval can wait without any subsequent bus event
4038
4617
  timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
4039
4618
  signal?.addEventListener("abort", aborted, { once: true });
@@ -4058,8 +4637,13 @@ export async function startDaemon(opts: DaemonOptions) {
4058
4637
  replacingPi.add(pi);
4059
4638
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
4060
4639
  }
4061
- function startPeer(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 }, replacementAuthority?: symbol): Promise<Record<string, unknown>> {
4640
+ function startPeer(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 }, replacementAuthority?: symbol, startCurrent?: () => void): Promise<Record<string, unknown>> {
4641
+ startCurrent?.();
4642
+ if (startCurrent && starting.has(peer)) return Promise.resolve({ ok: false, error: "controlled recovery cannot join another pending start" });
4643
+ if (peer.includes("@")) return Promise.resolve({ ok: false, error: "remote peers have no local runtime or control authority" });
4644
+ if (isExternalInstance(peer) && (Object.keys(args).some(key => !["mode", "sessionId"].includes(key)) || args.mode !== undefined && args.mode !== "headless")) return Promise.resolve({ ok: false, error: "external module instances permit only a headless start and recorded session id" });
4062
4645
  if (args.replaceSession !== undefined && (args.replaceSession !== true || replacementAuthority !== piReplacementAuthority || definitionOf(peer)?.start.daemonPort !== "pi-rpc")) return Promise.resolve({ ok: false, error: "Pi session replacement requires explicit person authorization" });
4646
+ if (externalRefusedRows.has(peer)) return Promise.resolve({ ok: false, error: "instance kind is unavailable in the startup catalog; check its kind spelling and person-owned module declarations, then restart the hub" });
4063
4647
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
4064
4648
  if (!membershipAllowed(peer)) return Promise.resolve({ ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` });
4065
4649
  if (rosterChanging === peer) return Promise.resolve({ ok: false, error: `${peer} roster change is in progress` });
@@ -4072,11 +4656,13 @@ export async function startDaemon(opts: DaemonOptions) {
4072
4656
  return joined;
4073
4657
  }
4074
4658
  if (definitionOf(peer)?.permission.trackUnattended) startingUnattended.set(peer, args.unattended === true);
4075
- const running = startPeerOnce(peer, args).then(async result => {
4659
+ const running = startPeerOnce(peer, args, startCurrent).then(async result => {
4660
+ startCurrent?.();
4076
4661
  if (!result.ok && definitionOf(peer)?.permission.trackUnattended) unattendedPeers.delete(peer);
4077
4662
  if (!result.ok || result.already) return result;
4078
4663
  if (definitionOf(peer)?.session.restoreOnStart) recordSessions();
4079
4664
  return permissionChange(peer, async () => {
4665
+ startCurrent?.();
4080
4666
  if (definitionOf(peer)?.permission.trackUnattended) { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
4081
4667
  logPermissionStart(peer); writeStatus(); return result;
4082
4668
  }, true);
@@ -4132,7 +4718,9 @@ export async function startDaemon(opts: DaemonOptions) {
4132
4718
  });
4133
4719
  // Printed for a person's explicit reconciliation only; never executed by auto-start.
4134
4720
  const continuityCleanup = () => `After inspection, archive the active bookmark with ${["mv", "-n", "--", join(opts.stateDir, "recovery-continuity.json"), join(opts.stateDir, `recovery-continuity.retired.manual.${randomUUID()}.json`)].map(shellQuote).join(" ")} before choosing another session`;
4135
- async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
4721
+ async function hubStart(peer: unknown, by: string, person = false): Promise<Record<string, unknown>> {
4722
+ if (typeof peer === "string" && peer.includes("@")) return { ok: false, error: "remote peers have no local runtime or control authority" };
4723
+ if (typeof peer === "string" && isExternalInstance(peer) && !person) return { ok: false, error: "external module instances require an explicit person start" };
4136
4724
  if (stopping) return { ok: false, error: "hub is stopping" };
4137
4725
  if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
4138
4726
  if (!isStartablePeer(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, qwen (when on roster), pi (when enabled) and local" };
@@ -4212,7 +4800,7 @@ export async function startDaemon(opts: DaemonOptions) {
4212
4800
  if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
4213
4801
  if (!owner || owner.hubOwned !== true) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
4214
4802
  if (owner.form === "tui") return { ok: false, error: `${definitionOf(peer)?.displayName ?? peer} belongs to a native TUI terminal; end it in its terminal` };
4215
- if (owner.state === "offline" && owner.pendingOwnedProcess !== true) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
4803
+ if (owner.state === "offline" && (isExternalInstance(peer) ? owner.pendingOwnedProcess === false : owner.pendingOwnedProcess !== true)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
4216
4804
  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" };
4217
4805
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
4218
4806
  try {
@@ -4230,7 +4818,7 @@ export async function startDaemon(opts: DaemonOptions) {
4230
4818
  await owner.stop(reason);
4231
4819
  writeStatus();
4232
4820
  const state = bus.stateOf(peer);
4233
- if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
4821
+ if (state !== "offline" || bus.peers.get(peer) !== owner || isExternalInstance(peer) && owner.pendingOwnedProcess !== false) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
4234
4822
  try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by ${operational?.actor ?? "the console"}; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
4235
4823
  return { ok: true, state };
4236
4824
  });
@@ -4257,31 +4845,35 @@ export async function startDaemon(opts: DaemonOptions) {
4257
4845
  };
4258
4846
  }
4259
4847
 
4260
- async function startPeerOnce(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 }): Promise<Record<string, unknown>> {
4848
+ async function startPeerOnce(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 }, startCurrent?: () => void): Promise<Record<string, unknown>> {
4849
+ startCurrent?.();
4261
4850
  let unmute: (() => void) | undefined;
4262
4851
  try {
4852
+ if (isExternalInstance(peer) && bus.peers.get(peer)?.state !== undefined && bus.peers.get(peer)?.state !== "offline" && !currentExecutionOwner(peer)) return { ok: false, error: "external instance owner binding changed; stop the retained owner before starting again" };
4263
4853
  if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
4264
4854
  let executionCwd: string;
4265
- try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
4855
+ try { executionCwd = resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
4266
4856
  catch (error) { return { ok: false, error: (error as Error).message }; }
4267
4857
  executionRoots.set(peer, executionCwd);
4268
4858
  applyPendingPermissionDefault(peer);
4269
- if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
4270
- const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd);
4859
+ if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); startCurrent?.(); await refreshRunner(); startCurrent?.(); }
4860
+ const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd, startCurrent);
4861
+ startCurrent?.();
4271
4862
  return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
4272
4863
  } finally {
4273
4864
  unmute?.();
4274
4865
  }
4275
4866
  }
4276
4867
 
4277
- 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>> {
4868
+ 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, startCurrent?: () => void): Promise<Record<string, unknown>> {
4869
+ startCurrent?.();
4278
4870
  const adapter = definitionOf(peer);
4279
4871
  if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
4280
4872
  && (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
4281
4873
  return { ok: false, error: "configured ACP session selector is invalid" };
4282
4874
  }
4283
4875
  const retainedOwner = bus.peers.get(peer);
4284
- if (adapter?.permission.genericAcp && retainedOwner?.state === "offline" && "pendingOwnedProcess" in retainedOwner && retainedOwner.pendingOwnedProcess !== false) return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
4876
+ if ((adapter?.permission.genericAcp || isExternalInstance(peer)) && retainedOwner?.state === "offline" && (isExternalInstance(peer) || "pendingOwnedProcess" in retainedOwner) && retainedOwner.pendingOwnedProcess !== false) return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
4285
4877
  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" };
4286
4878
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
4287
4879
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
@@ -4326,7 +4918,7 @@ export async function startDaemon(opts: DaemonOptions) {
4326
4918
  recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
4327
4919
  args = { ...args, ...selected };
4328
4920
  mute(existing);
4329
- await stopPiForReplacement(existing);
4921
+ await stopPiForReplacement(existing); startCurrent?.();
4330
4922
  }
4331
4923
  if (isPiPeer(existing) && !args.replaceSession) {
4332
4924
  let saved = existing.recoveryMetadata();
@@ -4343,18 +4935,18 @@ export async function startDaemon(opts: DaemonOptions) {
4343
4935
  // process from the abandoned CLI cannot claim the replacement owner.
4344
4936
  // The replacement's start event is the only state change the console should see (issue #42).
4345
4937
  mute(existing);
4346
- await stopPiForReplacement(existing);
4938
+ await stopPiForReplacement(existing); startCurrent?.();
4347
4939
  } else if (changesOwner) {
4348
- saved = await existing.captureResume();
4940
+ saved = await existing.captureResume(); startCurrent?.();
4349
4941
  if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
4350
4942
  args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
4351
4943
  // Same as the unclaimed handover: no offline flash between the adapters (issue #42).
4352
4944
  mute(existing);
4353
- await stopPiForReplacement(existing);
4945
+ await stopPiForReplacement(existing); startCurrent?.();
4354
4946
  } else if (existing.state !== "offline") {
4355
4947
  return mode === "tui" ? { ok: false, error: "Pi already owns a native terminal; use that terminal or switch to headless first" } : { ok: true, already: true };
4356
4948
  } else if (saved.sessionId) {
4357
- saved = await existing.captureResume();
4949
+ saved = await existing.captureResume(); startCurrent?.();
4358
4950
  args = { ...args, backend: args.backend ?? launch.backend as "auto" | "dgx" | "mlx", model: args.model ?? (args.backend === undefined && typeof launch.model === "string" ? launch.model : undefined), sessionId: String(saved.sessionId), sessionFile: typeof saved.sessionFile === "string" ? saved.sessionFile : undefined };
4359
4951
  }
4360
4952
  }
@@ -4371,6 +4963,7 @@ export async function startDaemon(opts: DaemonOptions) {
4371
4963
  if (!adapter?.start.reconfigureWhileAttached) {
4372
4964
  if (isPiPeer(existing)) await stopPiForReplacement(existing);
4373
4965
  else await existing?.stop();
4966
+ startCurrent?.();
4374
4967
  }
4375
4968
  if (adapter?.start.createTui && args.mode === "tui") {
4376
4969
  // #286 Part 3: Qwen's own TUI, driven through its dual-output files. The person's terminal runs it; the hub prepares the files and returns the launch.
@@ -4379,6 +4972,7 @@ export async function startDaemon(opts: DaemonOptions) {
4379
4972
  if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4380
4973
  // The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
4381
4974
  const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
4975
+ startCurrent?.();
4382
4976
  if (!verdict.ok) return { ok: false, error: verdict.reason };
4383
4977
  if (verdict.note) log(verdict.note);
4384
4978
  if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
@@ -4386,6 +4980,7 @@ export async function startDaemon(opts: DaemonOptions) {
4386
4980
  const nativeRoot = nativeStateDir(peer);
4387
4981
  mkdirSync(nativeRoot, { recursive: true });
4388
4982
  const qwenState = join(nativeRoot, "qwen");
4983
+ startCurrent?.();
4389
4984
  const tui: QwenDualPeer = adapter.start.createTui({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["qwen-dual"]({
4390
4985
  cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
4391
4986
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
@@ -4400,10 +4995,10 @@ export async function startDaemon(opts: DaemonOptions) {
4400
4995
  onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
4401
4996
  log,
4402
4997
  }) }) as QwenDualPeer;
4403
- recoveryTaskPreface(peer); await ensurePreface(peer);
4998
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
4404
4999
  executionOwners.set(peer, { owner: tui, cwd: executionCwd });
4405
5000
  try {
4406
- await tui.start();
5001
+ startCurrent?.(); await tui.start(); startCurrent?.();
4407
5002
  bus.add(tui);
4408
5003
  if (tui.state !== "offline") tui.onState?.(tui.state);
4409
5004
  } catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
@@ -4413,6 +5008,7 @@ export async function startDaemon(opts: DaemonOptions) {
4413
5008
  if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
4414
5009
  // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
4415
5010
  const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
5011
+ startCurrent?.();
4416
5012
  if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
4417
5013
  if (jcodeVerdict.note) log(jcodeVerdict.note);
4418
5014
  // #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
@@ -4420,20 +5016,36 @@ export async function startDaemon(opts: DaemonOptions) {
4420
5016
  // #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
4421
5017
  // written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
4422
5018
  if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
5019
+ startCurrent?.();
4423
5020
  const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ peer, command: config.jcode_cmd, home: instance?.jcode_home ?? config.jcode_home,
4424
5021
  providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
4425
5022
  ...(instance?.provider ? { provider: instance.provider } : {}),
4426
5023
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
4427
- preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
5024
+ preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
5025
+ // #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
5026
+ // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
5027
+ onUsageDiagnostic: (observation, sessionId) => {
5028
+ if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
5029
+ const current = contextSession(peer); if (!current) return;
5030
+ const reading = readNativeContext(adapter, { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
5031
+ if (reading) contexts.report(peer, reading, current);
5032
+ },
5033
+ // One prompt's own counters (a sum over its provider calls), recorded as a usage event and never as a running total: `tokens` events and the hub token window stay as they were.
5034
+ onTurnUsage: (usage) => {
5035
+ if (bus.peers.get(peer) !== jcode) return; // a replaced adapter's late result is not this peer's
5036
+ event({ type: "usage", peer, source: "jcode_acp", id: `jcode:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens,
5037
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), ...taskAttribution(peer) });
5038
+ } }) }) as JcodePeer;
4428
5039
  jcodeLaunches.set(peer, jcode);
4429
- recoveryTaskPreface(peer); await ensurePreface(peer);
4430
- try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
5040
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
5041
+ try { startCurrent?.(); await jcode.start(); startCurrent?.(); await reconcileNativeStart(peer, jcode, startCurrent); }
4431
5042
  catch { await jcode.stop().catch(() => {}); if (jcodeLaunches.get(peer) === jcode) jcodeLaunches.delete(peer); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
4432
5043
  return { ok: true, policy: "unmanaged" };
4433
5044
  }
4434
5045
  if (adapter?.start.acpCommand) {
4435
5046
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
4436
5047
  actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
5048
+ startCurrent?.();
4437
5049
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
4438
5050
  onActivity: item => activityRings[peer]?.append(item),
4439
5051
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
@@ -4474,10 +5086,10 @@ export async function startDaemon(opts: DaemonOptions) {
4474
5086
  preamble: sessionRoleContract(peer),
4475
5087
  }) }) as AcpPeer;
4476
5088
  recoveryTaskPreface(peer);
4477
- await ensurePreface(peer);
5089
+ await ensurePreface(peer); startCurrent?.();
4478
5090
  try {
4479
- await kimi.start();
4480
- await reconcileNativeStart(peer, kimi);
5091
+ startCurrent?.(); await kimi.start(); startCurrent?.();
5092
+ await reconcileNativeStart(peer, kimi, startCurrent);
4481
5093
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
4482
5094
  return { ok: true };
4483
5095
  }
@@ -4488,12 +5100,17 @@ export async function startDaemon(opts: DaemonOptions) {
4488
5100
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
4489
5101
  const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
4490
5102
  actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
4491
- const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
5103
+ const externalBinding = isExternalInstance(peer) ? captureExternalBinding(peer, args.sessionId) : undefined;
5104
+ const externalWorkspace = externalBinding ? externalWorkspaceIdentity(executionCwd) : undefined;
5105
+ if (externalBinding && permission !== "read-only" && defaultSources[peer] && !adapter.permission.genericAcp?.modes?.[permission]) throw new Error("external instance configured permission mode has no native mapping");
5106
+ const externalLaunchId = externalBinding ? randomUUID() : undefined;
5107
+ if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
5108
+ const acpOptions: AcpOptions = {
4492
5109
  onActivity: item => activityRings[peer]?.append(item),
4493
5110
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4494
5111
  actionGate: acpActionGate(peer),
4495
5112
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
4496
- ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
5113
+ ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: { ...(adapter.permission.genericAcp ?? {}), ...(readOnlyPeer(peer) ? { lockedReadOnly: true } : {}) } } : {}),
4497
5114
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
4498
5115
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
4499
5116
  autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
@@ -4508,13 +5125,37 @@ export async function startDaemon(opts: DaemonOptions) {
4508
5125
  onTurnFailure: () => { supervisionTurns.delete(peer); },
4509
5126
  mcpServers,
4510
5127
  preamble: sessionRoleContract(peer),
4511
- } } });
5128
+ };
5129
+ if (externalBinding) {
5130
+ if (acpOptions.nativePolicy?.modes) Object.freeze(acpOptions.nativePolicy.modes);
5131
+ if (acpOptions.nativePolicy) Object.freeze(acpOptions.nativePolicy);
5132
+ Object.freeze(acpOptions.cmd); for (const server of mcpServers) { server.env.forEach(Object.freeze); Object.freeze(server.env); Object.freeze(server.args); Object.freeze(server); } Object.freeze(mcpServers); Object.freeze(acpOptions);
5133
+ externalInstanceStateDir(opts.stateDir, peer);
5134
+ }
5135
+ startCurrent?.();
5136
+ const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, ...(externalBinding ? { instanceStateDir: nativeStateDir(peer) } : {}), kindConfig: undefined, permission, mcpServers, daemon: { acp: acpOptions } });
5137
+ if (externalBinding && externalLaunchId) externalLaunches.set(peer, { owner, options: acpOptions, launchId: externalLaunchId, binding: externalBinding, workspace: externalWorkspace! });
5138
+ assertNativeProofPorts(adapter, owner);
4512
5139
  if (!isAcpPeer(owner) || owner.id !== peer || owner.deliveryOnly !== true || owner.hubOwned !== true || owner.form !== "headless") throw new Error("ACP factory did not return its bound native transport");
4513
- recoveryTaskPreface(peer); await ensurePreface(peer);
4514
- try { await owner.start(); await reconcileNativeStart(peer, owner); }
5140
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
5141
+ try {
5142
+ if (externalBinding && (!membershipAllowed(peer) || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || !sameExternalInstanceBinding(externalBinding, captureExternalBinding(peer, args.sessionId)))) throw new Error("external instance binding changed before native spawn");
5143
+ startCurrent?.(); await owner.start(); startCurrent?.();
5144
+ if (externalBinding) {
5145
+ if (!await verifyManagedAcpLaunch(owner, acpOptions)) throw new Error("external ACP managed launch is unverified");
5146
+ startCurrent?.();
5147
+ const receipt = managedAcpLaunch(owner, acpOptions), current = captureExternalBinding(peer, args.sessionId);
5148
+ if (!receipt || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || receipt.cwd !== executionCwd || !sameExternalInstanceBinding(externalBinding, current)) throw new Error("external instance startup binding changed");
5149
+ externalLaunches.get(peer)!.generation = receipt.generation;
5150
+ externalBindings.set(peer, externalBinding);
5151
+ externalSessions.set(peer, receipt.sessionId);
5152
+ writeExternalInstanceRecord(opts.stateDir, externalBinding, receipt.sessionId);
5153
+ }
5154
+ await reconcileNativeStart(peer, owner, startCurrent);
5155
+ }
4515
5156
  catch (error) {
4516
5157
  await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
4517
- if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
5158
+ if (configuredIds.includes(adapter.kind) && (isExternalInstance(peer) ? owner.pendingOwnedProcess !== false : owner.pendingOwnedProcess)) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
4518
5159
  if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
4519
5160
  if (error instanceof QwenIdentityError) throw error;
4520
5161
  throw new Error(`${adapter.displayName} could not reach its provider or complete its startup handshake; verify the provider connection and native CLI${isCompatId(adapter.kind) ? ` (${describe(adapter.kind)})` : ""}; see ahub agents (details withheld)`);
@@ -4525,9 +5166,11 @@ export async function startDaemon(opts: DaemonOptions) {
4525
5166
  if (args.unattended && permissionMode(peer) !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask first, or restart without --unattended" };
4526
5167
  let codexSteering = false;
4527
5168
  actionBudgets.revoke(peer);
5169
+ startCurrent?.();
4528
5170
  const codex: CodexPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["codex-appserver"]({
4529
5171
  admitAttach: () => membershipAllowed(peer),
4530
5172
  permissionMode: permissionMode(peer),
5173
+ ...(declarations[peer] ? { threadSandbox: () => isPlannerOnly(config.roles[peer] ?? []) ? declarations[peer]?.worktree ? "unavailable" as const : "read-only" as const : undefined } : {}),
4531
5174
  actionGate: acpActionGate(peer), onPermission,
4532
5175
  onTokens: (added) => void addTokens(peer, added),
4533
5176
  // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
@@ -4593,10 +5236,10 @@ export async function startDaemon(opts: DaemonOptions) {
4593
5236
  log,
4594
5237
  }) }) as CodexPeer;
4595
5238
  recoveryTaskPreface(peer);
4596
- await ensurePreface(peer);
5239
+ await ensurePreface(peer); startCurrent?.();
4597
5240
  try {
4598
- await codex.start();
4599
- await reconcileNativeStart(peer, codex);
5241
+ startCurrent?.(); await codex.start(); startCurrent?.();
5242
+ await reconcileNativeStart(peer, codex, startCurrent);
4600
5243
  } catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
4601
5244
  const proxy = frontendProxyState(peer, codex);
4602
5245
  if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
@@ -4621,6 +5264,7 @@ export async function startDaemon(opts: DaemonOptions) {
4621
5264
  },
4622
5265
  });
4623
5266
  modelRelays.set(peer, modelRelay);
5267
+ startCurrent?.();
4624
5268
  if (!declarations[peer]) globalModelRelay(modelRelay);
4625
5269
  const nativeRoot = nativeStateDir(peer);
4626
5270
  mkdirSync(nativeRoot, { recursive: true });
@@ -4684,6 +5328,7 @@ export async function startDaemon(opts: DaemonOptions) {
4684
5328
  },
4685
5329
  };
4686
5330
  const routing = activeRouting();
5331
+ startCurrent?.();
4687
5332
  const pi: PiPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["pi-rpc"]({
4688
5333
  cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
4689
5334
  model: args.model,
@@ -4793,11 +5438,11 @@ export async function startDaemon(opts: DaemonOptions) {
4793
5438
  watchdogMs: config.watchdog_ms, maxSteps: config.pi.max_steps, log,
4794
5439
  }) }) as PiPeer;
4795
5440
  recoveryTaskPreface(peer);
4796
- await ensurePreface(peer);
5441
+ await ensurePreface(peer); startCurrent?.();
4797
5442
  executionOwners.set(peer, { owner: pi, cwd: executionCwd });
4798
5443
  bus.add(pi);
4799
5444
  // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
4800
- try { await pi.start(); } catch (error) {
5445
+ try { startCurrent?.(); await pi.start(); } catch (error) {
4801
5446
  await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
4802
5447
  if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
4803
5448
  throw error;
@@ -4814,9 +5459,10 @@ export async function startDaemon(opts: DaemonOptions) {
4814
5459
  // It stays held; holdPeer validates the restored choice before an operator can resume it.
4815
5460
  if (recoveryActive() && manualPaused.has(peer)) log("local: availability deferred while controlled recovery retains its manual pause");
4816
5461
  else await validateLocalChoice(route, fixedModel);
5462
+ startCurrent?.();
4817
5463
  // Validation can await network: a queued delivery may have started meanwhile.
4818
5464
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
4819
- if (existing) { mute(existing); await existing.stop(); }
5465
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
4820
5466
  const capture = config.memory.enabled
4821
5467
  ? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
4822
5468
  : undefined;
@@ -4858,6 +5504,7 @@ export async function startDaemon(opts: DaemonOptions) {
4858
5504
  shellProofs.ordinary(input, current);
4859
5505
  return current() ? true : "aborted";
4860
5506
  };
5507
+ startCurrent?.();
4861
5508
  const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
4862
5509
  cwd: executionCwd,
4863
5510
  omni,
@@ -4872,7 +5519,11 @@ export async function startDaemon(opts: DaemonOptions) {
4872
5519
  turnId: () => turns.get(peer)?.id,
4873
5520
  onTool: (observation, task) => observeProgress(peer, observation, task),
4874
5521
  fixedModel: args.model ?? routing.local.fixed_model,
4875
- tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, readOnly: config.data?.readonly, permit, shellExecutionCheck: input => shellProofs.check(input) },
5522
+ tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, readOnly: config.data?.readonly, permit, shellExecutionCheck: input => shellProofs.check(input),
5523
+ // #515: `doc_read` exists only when the project opted in. The projections are this worker's execution root's (the hub's own facts and review use the same ones).
5524
+ // #515b: `web_fetch` exists only when the project listed a host. A PII turn is refused inside the tool, so the port carries no PII logic of its own.
5525
+ ...(webEgress ? { webFetch: { allow: webAllow, proxyUrl: () => webEgress?.url, ...(opts.webFetchTransport ? { fetch: opts.webFetchTransport } : {}) } } : {}),
5526
+ ...(config.local.doc_read === true ? { docRead: { projections: () => projectionsFor(peer), piiPaths: () => config.pii?.paths ?? [], piiPatterns: () => activeRouting().signals.pii_patterns } } : {}) },
4876
5527
  onApprovalStop: reason => notify(reason),
4877
5528
  ...(capture ? { capture } : {}),
4878
5529
  profile: resolvedProfile,
@@ -4890,24 +5541,25 @@ export async function startDaemon(opts: DaemonOptions) {
4890
5541
  log,
4891
5542
  }) }) as LocalPeer;
4892
5543
  recoveryTaskPreface(peer);
4893
- await ensurePreface(peer);
5544
+ await ensurePreface(peer); startCurrent?.();
4894
5545
  executionOwners.set(peer, { owner: local, cwd: executionCwd });
4895
5546
  bus.add(local);
4896
- await local.start();
5547
+ startCurrent?.(); await local.start();
4897
5548
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
4898
5549
  }
4899
5550
  if (adapter?.start.daemonPort === "claude-implementer") {
4900
5551
  if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
4901
5552
  const currentRoot = () => {
4902
5553
  const roles = config.roles[peer] ?? [];
4903
- return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveInstanceExecutionCwd(opts.cwd, peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
5554
+ return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveExecutionCwd(peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
4904
5555
  };
4905
5556
  try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
4906
5557
  catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
4907
5558
  if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
4908
- if (existing) { mute(existing); await existing.stop(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
5559
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
4909
5560
  const selected = permissionMode(peer);
4910
5561
  if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
5562
+ startCurrent?.();
4911
5563
  const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4912
5564
  cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
4913
5565
  factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms, ...(declarations[peer]?.model ? { model: declarations[peer].model } : {}),
@@ -4920,6 +5572,25 @@ export async function startDaemon(opts: DaemonOptions) {
4920
5572
  } catch { return false; }
4921
5573
  },
4922
5574
  chatTask: () => implementerChatTask(peer),
5575
+ failureTrace: (launch, nativeVersion) => {
5576
+ const trace = createClaudeFailureTrace({ instanceId, nativeVersion, current: () => {
5577
+ const active = implementer.ownedTools?.(launch.launchId);
5578
+ return bus.peers.get(peer) === implementer && implementerFailures.get(implementer) === entry
5579
+ && active?.generation === launch.generation && active.signal === launch.signal && !launch.signal.aborted;
5580
+ } });
5581
+ const entry: ImplementerFailureEntry = { launchId: launch.launchId, generation: launch.generation, signal: launch.signal, trace, probeCount: 0, permissionAttempt: 0 };
5582
+ implementerFailures.set(implementer, entry);
5583
+ return trace;
5584
+ },
5585
+ failurePrivate: taskId => {
5586
+ try {
5587
+ if (bus.peers.get(peer) !== implementer || !turns.has(peer) || contextPii(peer)) return true;
5588
+ if (taskId === undefined) return false;
5589
+ const task = board.get(taskId);
5590
+ return !task || task.owner !== peer || tasks.isPii(task);
5591
+ } catch { return true; }
5592
+ },
5593
+ failureLog: message => log(message),
4923
5594
  transcriptPersisted: (task, session) => {
4924
5595
  const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
4925
5596
  return path ? pathEvidence(path, lstatSync) : "unknown";
@@ -4932,10 +5603,11 @@ export async function startDaemon(opts: DaemonOptions) {
4932
5603
  ...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
4933
5604
  }, notify,
4934
5605
  }) });
4935
- await implementer.start();
5606
+ startCurrent?.(); await implementer.start();
4936
5607
  try {
5608
+ startCurrent?.();
4937
5609
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4938
- recoveryTaskPreface(peer); await ensurePreface(peer);
5610
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
4939
5611
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4940
5612
  executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
4941
5613
  return { ok: true };
@@ -4946,8 +5618,9 @@ export async function startDaemon(opts: DaemonOptions) {
4946
5618
  if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
4947
5619
  // #519: each backend proves isolation before admission, then starts one fresh child per review.
4948
5620
  if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
4949
- if (existing) { mute(existing); await existing.stop(); }
5621
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
4950
5622
  const reviewPolicies = new Map<string, string>();
5623
+ startCurrent?.();
4951
5624
  const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
4952
5625
  backend, cwd: executionCwd,
4953
5626
  mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
@@ -4994,9 +5667,10 @@ export async function startDaemon(opts: DaemonOptions) {
4994
5667
  },
4995
5668
  log,
4996
5669
  }) }) as ReviewerPrintPeer;
4997
- try { await reviewer.start(); }
5670
+ try { startCurrent?.(); await reviewer.start(); startCurrent?.(); }
4998
5671
  catch (error) {
4999
5672
  await reviewer.stop().catch(() => {});
5673
+ startCurrent?.();
5000
5674
  // #691: a version refusal names only versions and the trust command, so it is shown for every backend.
5001
5675
  const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
5002
5676
  : reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
@@ -5076,11 +5750,12 @@ export async function startDaemon(opts: DaemonOptions) {
5076
5750
  status: () => {
5077
5751
  const quota = budget.status();
5078
5752
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
5753
+ const doing = doingRows(ids);
5079
5754
  return {
5080
5755
  peers: ids.map(peer => {
5081
5756
  const summary = bus.queueSummary(peer);
5082
5757
  return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
5083
- manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
5758
+ manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null, doing: doing[peer] ?? null,
5084
5759
  ...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source, kind: w.kind, ...(w.superseded ? { superseded: true } : {}) })) } : {}) };
5085
5760
  }),
5086
5761
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
@@ -5122,6 +5797,7 @@ export async function startDaemon(opts: DaemonOptions) {
5122
5797
  const privateDelivery = originals.some((env) => env.private || publicOnly && env.refs?.task && (()=>{const task=board.get(Number(env.refs!.task));return !task || tasks.isPii(task);})());
5123
5798
  return {
5124
5799
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
5800
+ ...roomChat.evidence(row.peer, row.id),
5125
5801
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
5126
5802
  createdAt: row.createdAt, updatedAt: row.updatedAt,
5127
5803
  envelopeIds: originals.map((env) => env.id),
@@ -5206,7 +5882,7 @@ export async function startDaemon(opts: DaemonOptions) {
5206
5882
  rows: report.rows.map(row => row.private || privatePrs.has(row.pr) ? { private: true, ready: false, tasks: [], after: [], blockers: ["private lifecycle evidence unavailable"] }
5207
5883
  : mixed ? { pr: row.pr, tasks: row.tasks.filter(id => !privateIds.has(id)), ready: false, after: [], blockers: ["private dependency evidence unavailable"] }
5208
5884
  : { pr: row.pr, tasks: row.tasks, head: row.head, ready: row.ready, blockers: row.blockers, after: row.after,
5209
- ...(row.findings ? { findings: row.findings } : {}), ...(row.planConformance ? { planConformance: row.planConformance } : {}), ...(row.testIntegrity ? { testIntegrity: row.testIntegrity } : {}) }),
5885
+ ...(row.findings ? { findings: row.findings } : {}), ...(row.planConformance ? { planConformance: row.planConformance } : {}), ...(row.testIntegrity ? { testIntegrity: row.testIntegrity } : {}), ...(row.evalGate ? { evalGate: row.evalGate } : {}) }),
5210
5886
  order: mixed ? [] : report.order, ...(report.unordered ? { unordered: mixed ? [] : report.unordered } : {}), conflicts: mixed ? [] : report.conflicts });
5211
5887
  }
5212
5888
  /** A public read may use host GitHub authentication only for this project's current public scope. */
@@ -5218,14 +5894,23 @@ export async function startDaemon(opts: DaemonOptions) {
5218
5894
  || githubOriginRepository(opts.cwd) === identity;
5219
5895
  }
5220
5896
  const publicReadPrivacy = () => JSON.stringify(board.list().map(task => [task.id, tasks.isPii(task), task.history.length]));
5221
- function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
5897
+ function operationalCurrent(sock:Sock,acceptedAfterClose=false,committedRecovery=false):()=>void {
5222
5898
  const client=sock.data,peer=client.peer;
5223
5899
  return ()=>{
5224
- if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping || !membershipAllowed(peer) || contextPii(peer))throw new OperationalAuthorityError("operational conductor authority unavailable");
5900
+ if(!peer || sock.data!==client || client.role!=="tools" || !acceptedAfterClose && sock.readyState!==WebSocket.OPEN || client.toolsOwner || readOnlyPeer(peer) || stopping && !committedRecovery || !membershipAllowed(peer) || contextPii(peer))throw new OperationalAuthorityError("operational conductor authority unavailable");
5225
5901
  try {requireConductor(peer,config.roles);}
5226
5902
  catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
5227
5903
  };
5228
5904
  }
5905
+ function currentRecoveryPolicy(peer: string) {
5906
+ const inputs = readRecoveryPolicyInputs(opts.cwd, runningRoutingDefault);
5907
+ const candidate = loadConfig(opts.cwd, inputs.layers, inputs.routing.retired ?? [], []);
5908
+ assertRecoverySourceInputs(acceptedSources.trustLayers, inputs);
5909
+ // Compare accepted declarations; config.roles also contains the settled planner projection.
5910
+ assertRecoveryConfiguration({ ...config, roles: policyRoles }, candidate);
5911
+ assertRecoveryRouting(activeRouting(), inputs.routing);
5912
+ return captureRecoveryPolicy({ root: opts.cwd, id: projectId, stateDir: opts.stateDir }, peer, candidate, inputs);
5913
+ }
5229
5914
  function admitOperational(sock:Sock,command:string):()=>void {
5230
5915
  const current=operationalCurrent(sock);current();
5231
5916
  if(!event({type:"agent_cli",peer:sock.data.peer!,command,refused:false}))throw new Error("CLI audit event was not retained");
@@ -5316,7 +6001,7 @@ export async function startDaemon(opts: DaemonOptions) {
5316
6001
  return ta && tb && CONFLICT_STATES.includes(ta.state) && CONFLICT_STATES.includes(tb.state) ? [{ kind: "conflict" as const, tasks: conflict.tasks, peers: conflict.peers }] : [];
5317
6002
  }),
5318
6003
  ];
5319
- const attentionStatus = status(false, true);
6004
+ const attentionStatus = status(false, true, true);
5320
6005
  let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
5321
6006
  const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
5322
6007
  const usageView = (open: boolean) => {
@@ -5361,11 +6046,14 @@ export async function startDaemon(opts: DaemonOptions) {
5361
6046
  projectId,
5362
6047
  instanceId,
5363
6048
  attention: attentionStatus.attentionInput,
6049
+ rooms: attentionStatus.rooms,
5364
6050
  pet,
5365
- status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
6051
+ status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo, ownerIsolation: attentionStatus.ownerIsolation, ownerIsolationNotes: attentionStatus.ownerIsolationNotes },
5366
6052
  boardCapabilities: { backlog: true },
5367
6053
  planningCapabilities: { enabled: true, wipPull: true, split: true },
5368
6054
  releases: tasks.releaseList(USER),
6055
+ // #335: each schedule's next run, ids and times only (a title or detail is task text). Absent when none is configured, so an older page and an unconfigured project see no change.
6056
+ ...(config.schedules.length ? { schedules: scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true) } : {}),
5369
6057
  boardLegend: Object.entries(peerView).map(([id, peer]) => ({
5370
6058
  peer: id, state: peer.state,
5371
6059
  permissionMode: "permissionNote" in peer && peer.permissionNote ? "unmanaged" : permissionDisplay(id, bus.peers.get(id)),
@@ -5376,7 +6064,7 @@ export async function startDaemon(opts: DaemonOptions) {
5376
6064
  tasks: boardTasks.map((task) => {
5377
6065
  const view = tasks.publicView(task);
5378
6066
  // Refs and history can themselves quote private text. The dashboard needs neither.
5379
- return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}), ...(() => { const deliverables = tasks.deliverableBadge(task); return deliverables ? { deliverables } : {}; })() };
6067
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, planner: view.planner ?? null, reviewer: task.reviewer, ...holderReasons(view), deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}), ...(() => { const deliverables = tasks.deliverableBadge(task); return deliverables ? { deliverables } : {}; })() };
5380
6068
  }),
5381
6069
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
5382
6070
  budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), ...(budgetNoteOf.get(id) ? { note: budgetNoteOf.get(id)!.note, noteKind: budgetNoteOf.get(id)!.kind } : {}), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
@@ -5559,6 +6247,7 @@ export async function startDaemon(opts: DaemonOptions) {
5559
6247
  const raw = valueAt(next, path.split("."));
5560
6248
  const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
5561
6249
  if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
6250
+ if (raw === undefined && valueAt(layer.raw, path.split(".")) === undefined) continue;
5562
6251
  putConfig(layer.raw, path, raw);
5563
6252
  }
5564
6253
  }
@@ -5568,7 +6257,7 @@ export async function startDaemon(opts: DaemonOptions) {
5568
6257
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
5569
6258
  texts["config.json"] = next["config.json"];
5570
6259
  texts["config.local.json"] = next["config.local.json"];
5571
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
6260
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
5572
6261
  const refusal = prepared.rejected.find(item => item.key === scope);
5573
6262
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
5574
6263
  return prepared.fileConfig;
@@ -5576,7 +6265,7 @@ export async function startDaemon(opts: DaemonOptions) {
5576
6265
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
5577
6266
  const texts = configSourceTexts(opts.cwd);
5578
6267
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
5579
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
6268
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
5580
6269
  const refusal = prepared.rejected.find(item => item.key === scope);
5581
6270
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
5582
6271
  }
@@ -5594,7 +6283,7 @@ export async function startDaemon(opts: DaemonOptions) {
5594
6283
  const texts = configSourceTexts(opts.cwd);
5595
6284
  fingerprint = configFingerprint(texts);
5596
6285
  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" };
5597
- const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
6286
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
5598
6287
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
5599
6288
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
5600
6289
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -5620,6 +6309,7 @@ export async function startDaemon(opts: DaemonOptions) {
5620
6309
  config.permission_default_sources = candidate.permission_default_sources;
5621
6310
  for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
5622
6311
  if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
6312
+ if (changes.some(change => change.key === "schedules" || change.key.startsWith("schedules.") || change.key === "folder_triggers" || change.key.startsWith("folder_triggers."))) rescheduleSchedules();
5623
6313
  if (changes.some(change => ["batch_max", "batch_ms", "queue_cap"].includes(change.key))) bus.configureScheduling({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap });
5624
6314
  if (changes.some(change => change.key === "watchdog_ms")) for (const peer of bus.peers.values()) if (peer instanceof BasePeer) peer.configureWatchdog(config.watchdog_ms);
5625
6315
  Object.assign(limits, config.limits);
@@ -5629,6 +6319,7 @@ export async function startDaemon(opts: DaemonOptions) {
5629
6319
  desiredRouting = structuredClone(prepared.routing);
5630
6320
  acknowledgeConfiguration(sources, prepared.accepted);
5631
6321
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
6322
+ if (ready && changes.some(change => change.key === "tasks.queue_backpressure" || change.key.startsWith("tasks.queue_backpressure."))) tasks.scheduleQueuePressureRecheck();
5632
6323
  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! }));
5633
6324
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
5634
6325
  retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
@@ -5768,7 +6459,7 @@ export async function startDaemon(opts: DaemonOptions) {
5768
6459
  await owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5769
6460
  if (definitionOf(peer)?.attach?.admission) clearKimiAdmission();
5770
6461
  }
5771
- else if (owner && owner.state !== "offline") {
6462
+ else if (owner && (owner.state !== "offline" || isExternalInstance(peer) && owner.pendingOwnedProcess !== false)) {
5772
6463
  const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
5773
6464
  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`);
5774
6465
  }
@@ -5821,13 +6512,17 @@ export async function startDaemon(opts: DaemonOptions) {
5821
6512
  if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
5822
6513
  return sanitize(reason.trim());
5823
6514
  };
5824
- async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
6515
+ /** `managed` (#514b) is the learn command's own definition: a function argument, never read from a request, so no other key or caller can reach the managed row. */
6516
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, managed?: SettingDef): Promise<Record<string, unknown>> {
5825
6517
  checkOperational(authority);
5826
6518
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
5827
6519
  let reason: string | undefined;
5828
6520
  try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
5829
6521
  if(authority==="conductor" && reason && !tasks.publicMetadataText(reason))return {ok:false,error:"setting reason withheld by privacy policy"};
5830
- const input = settingInput(key, raw);
6522
+ if (managed && (managed !== LEARN_CHECKLIST || authority !== "terminal")) return { ok: false, error: "a managed setting is written only by its own command, as the person" };
6523
+ let input: ReturnType<typeof settingInput>;
6524
+ if (managed) { try { input = { def: managed, value: checkSettingValue(managed, raw) }; } catch (error) { return { ok: false, error: (error as Error).message }; } }
6525
+ else input = settingInput(key, raw);
5831
6526
  if ("error" in input) return { ok: false, error: input.error };
5832
6527
  const { def, value } = input;
5833
6528
  const refusal = settingRefusal(def, value, authority);
@@ -5866,11 +6561,13 @@ export async function startDaemon(opts: DaemonOptions) {
5866
6561
  const result = await reloadConfig(authority, def.path.join("."));
5867
6562
  if (result.ok !== true) return { ...result, saved: true };
5868
6563
  const to = settingScalar(valueAt(storedConfig(), def.path));
5869
- recordSetting(def.key, from, to, source, false, reason);
6564
+ // A managed checklist is the person's own profile text: the event and the notice carry line counts, never the lines.
6565
+ const shown = (v: SettingValue): SettingValue => managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6566
+ recordSetting(def.key, shown(from), shown(to), source, false, reason);
5870
6567
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
5871
6568
  const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
5872
6569
  : ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
5873
- return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
6570
+ return { ok: true, text: `${def.key}: ${settingText(shown(to))} (${timing})` };
5874
6571
  } catch (error) {
5875
6572
  return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
5876
6573
  }
@@ -5887,8 +6584,9 @@ export async function startDaemon(opts: DaemonOptions) {
5887
6584
  if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
5888
6585
  if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
5889
6586
  }
5890
- const def = settingDef(identity?.key);
6587
+ const def = undoableDef(identity?.key);
5891
6588
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6589
+ if (def.managed && authority !== "terminal") return { ok: false, error: `${def.key} was written by ahub learn accept: put it back with ahub settings undo in a terminal` };
5892
6590
  if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
5893
6591
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
5894
6592
  const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
@@ -5899,9 +6597,11 @@ export async function startDaemon(opts: DaemonOptions) {
5899
6597
  undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
5900
6598
  const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
5901
6599
  if (result.ok !== true) return { ...result, saved: true };
5902
- recordSetting(def.key, from, read(), settingSource(authority), true, reason);
6600
+ // A managed checklist is the person's own profile text: the event, the notice and the reply carry its line count, never the lines (the same form as the write).
6601
+ const shown = (v: SettingValue): SettingValue => def.managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6602
+ recordSetting(def.key, shown(from), shown(read()), settingSource(authority), true, reason);
5903
6603
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
5904
- return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
6604
+ return { ok: true, text: `${def.key}: put back to ${settingText(shown(read()))}` };
5905
6605
  } catch (error) {
5906
6606
  return { ok: false, error: (error as Error).message };
5907
6607
  }
@@ -5940,8 +6640,9 @@ export async function startDaemon(opts: DaemonOptions) {
5940
6640
  }
5941
6641
 
5942
6642
  function settingsUndoPreview(): Record<string, unknown> {
5943
- const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
6643
+ const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
5944
6644
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6645
+ if (def.managed) return { ok: true, ...identity, lines: [`Undo ${def.key}: put back the previous machine-local file (the review checklist ahub learn accept changed; its lines are not shown here)`, "The saved previous machine-local file is revalidated before writing."] };
5945
6646
  const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
5946
6647
  if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
5947
6648
  return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
@@ -5956,7 +6657,7 @@ export async function startDaemon(opts: DaemonOptions) {
5956
6657
  if (file !== "config.local.json") return;
5957
6658
  const texts = configSourceTexts(opts.cwd);
5958
6659
  texts["config.local.json"] = text;
5959
- const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
6660
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
5960
6661
  const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
5961
6662
  // Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
5962
6663
  checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
@@ -6057,7 +6758,7 @@ export async function startDaemon(opts: DaemonOptions) {
6057
6758
  case "start_peer": {
6058
6759
  // #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
6059
6760
  if (!peer()) return bad;
6060
- const result = await hubStart(a.peer, "the dashboard");
6761
+ const result = await hubStart(a.peer, "the dashboard", session?.liveTest !== true);
6061
6762
  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)}` };
6062
6763
  // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
6063
6764
  if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
@@ -6082,6 +6783,7 @@ export async function startDaemon(opts: DaemonOptions) {
6082
6783
  }
6083
6784
  }
6084
6785
 
6786
+ const recoverySessionDigest = (id: string | undefined) => id === undefined ? undefined : createHash("sha256").update(id).digest("hex");
6085
6787
  const recoveryView = () => ({
6086
6788
  operationId: recoveryOperationId,
6087
6789
  phase: recoveryPhase,
@@ -6091,26 +6793,33 @@ export async function startDaemon(opts: DaemonOptions) {
6091
6793
  blockers: [
6092
6794
  ...(starting.size ? ["peer startup in progress"] : []),
6093
6795
  ...(!budget.recoverySettled ? ["budget transition in progress"] : []),
6094
- ...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
6796
+ ...([...bus.peers].filter(([id, peer]) => !id.includes("@") && peer.state === "busy").map(([id]) => `${id} is busy`)),
6095
6797
  ...(permissions.size ? ["pending approvals"] : []),
6096
6798
  // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
6097
6799
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
6098
6800
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
6099
6801
  ...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
6802
+ ...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
6803
+ ...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
6100
6804
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
6101
6805
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
6102
6806
  ...(releasing ? ["ready tasks being released"] : []),
6103
- ...([...bus.peers].filter(([, peer]) => peer.state !== "busy" && "recoveryReady" in peer && peer.recoveryReady !== true).map(([id, peer]) => !membershipAllowed(id) && peer.form === "tui" ? `${id} is not settled; end the retained ${definitionOf(id)?.displayName ?? id} TUI, then prepare again` : `${id} is not settled`)),
6807
+ ...([...bus.peers].filter(([id, peer]) => !id.includes("@") && peer.state !== "busy" && "recoveryReady" in peer && peer.recoveryReady !== true).map(([id, peer]) => !membershipAllowed(id) && peer.form === "tui" ? `${id} is not settled; end the retained ${definitionOf(id)?.displayName ?? id} TUI, then prepare again` : `${id} is not settled`)),
6104
6808
  ],
6105
6809
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
6106
6810
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
6107
6811
  const now = recoveryPeers()[peer.id];
6108
- if (recoveryPhase === "restored" || recoveryPhase === "released") return [peer.id, now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }];
6109
- return [peer.id, now ? { ...peer, ...now, queueIds: now.queueIds } : peer];
6812
+ const owner = bus.peers.get(peer.id), definition = definitionOf(peer.id);
6813
+ const observation = definition?.transport === "claude-channel" && isWsPeer(owner)
6814
+ ? { kind: definition.kind, attached: owner.attached, ...(now?.sessionId === undefined ? {} : { sessionDigest: recoverySessionDigest(now.sessionId) }) } : undefined;
6815
+ const visible = recoveryPhase === "restored" || recoveryPhase === "released" ? now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }
6816
+ : now ? { ...peer, ...now, queueIds: now.queueIds } : peer;
6817
+ return [peer.id, { ...visible, ...(observation === undefined ? {} : { observation }) }];
6110
6818
  })),
6111
6819
  });
6112
6820
  const recoveryError = (error: string) => ({ t: "recovery", ok: false, error });
6113
- async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
6821
+ async function recoveryOp(msg: any, checkCurrent: () => void = () => {}): Promise<Record<string, unknown>> {
6822
+ checkCurrent();
6114
6823
  if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
6115
6824
  if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
6116
6825
  const incompatibleCancellationTarget = () => {
@@ -6119,6 +6828,9 @@ export async function startDaemon(opts: DaemonOptions) {
6119
6828
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
6120
6829
  };
6121
6830
  const incompatibleRecoveryTarget = () => {
6831
+ if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
6832
+ if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
6833
+ if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
6122
6834
  const cancelled = incompatibleCancellationTarget();
6123
6835
  if (cancelled) return cancelled;
6124
6836
  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";
@@ -6137,8 +6849,9 @@ export async function startDaemon(opts: DaemonOptions) {
6137
6849
  }
6138
6850
 
6139
6851
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
6852
+ for (const [id, peer] of bus.peers) if (isExternalInstance(id) && (peer.state !== "offline" || peer.pendingOwnedProcess !== false)) return recoveryError("external module has no verified controlled-recovery driver; stop it explicitly and restart with its recorded native session id");
6140
6853
  for (const peer of bus.peers.values()) if (peer.captureResume && peer.state === "idle") {
6141
- try { await peer.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
6854
+ try { await peer.captureResume(); checkCurrent(); } catch (error) { return recoveryError((error as Error).message); }
6142
6855
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
6143
6856
  }
6144
6857
  }
@@ -6163,8 +6876,9 @@ export async function startDaemon(opts: DaemonOptions) {
6163
6876
  budget.setRecoveryHold(true);
6164
6877
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
6165
6878
  await bus.fenceRecovery();
6879
+ checkCurrent();
6166
6880
  { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
6167
- const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
6881
+ const blocked = [...bus.peers].filter(([id, peer]) => !id.includes("@") && peer.state === "busy").map(([id]) => id);
6168
6882
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
6169
6883
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
6170
6884
  recoveryPhase = "prepared";
@@ -6184,7 +6898,7 @@ export async function startDaemon(opts: DaemonOptions) {
6184
6898
  recoveryPhase = undefined;
6185
6899
  recoveryPeerSnapshot = undefined;
6186
6900
  clearTimeout(recoveryLeaseTimer);
6187
- budget.setRecoveryHold(false);
6901
+ budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6188
6902
  writeStatus();
6189
6903
  return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
6190
6904
  }
@@ -6224,11 +6938,12 @@ export async function startDaemon(opts: DaemonOptions) {
6224
6938
  }
6225
6939
  if (msg.op === "release") {
6226
6940
  if (recoveryPhase === "released") {
6227
- bus.setRecoveryHold(false); budget.setRecoveryHold(false);
6941
+ bus.setRecoveryHold(false); budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6228
6942
  return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
6229
6943
  }
6230
6944
  if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
6231
6945
  await bus.fenceRecovery();
6946
+ checkCurrent();
6232
6947
  const current = recoveryPeers();
6233
6948
  const missing = (recoveryPeerSnapshot ?? []).filter((saved) => saved.state !== "offline" && !manualPaused.has(saved.id) && !budget.record(saved.id) && (!current[saved.id] || current[saved.id]!.state === "offline"));
6234
6949
  if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
@@ -6240,7 +6955,7 @@ export async function startDaemon(opts: DaemonOptions) {
6240
6955
  recoveryPhase = "released";
6241
6956
  clearTimeout(recoveryLeaseTimer);
6242
6957
  bus.setRecoveryHold(false);
6243
- budget.setRecoveryHold(false);
6958
+ budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6244
6959
  writeStatus();
6245
6960
  return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
6246
6961
  }
@@ -6339,8 +7054,10 @@ export async function startDaemon(opts: DaemonOptions) {
6339
7054
  try { return await action(); } finally { permissionChanges.delete(peer); release(); }
6340
7055
  }
6341
7056
 
6342
- async function reconcileNativeStart(peer: string, owner: AcpPeer | CodexPeer): Promise<void> {
7057
+ async function reconcileNativeStart(peer: string, owner: AcpPeer | CodexPeer, startCurrent?: () => void): Promise<void> {
7058
+ startCurrent?.();
6343
7059
  const result = await permissionChange(peer, async () => {
7060
+ startCurrent?.();
6344
7061
  if (stopping) return { ok: false, error: "hub is stopping" };
6345
7062
  const selected = permissionMode(peer);
6346
7063
  const proxy = frontendProxyState(peer, owner);
@@ -6349,8 +7066,22 @@ export async function startDaemon(opts: DaemonOptions) {
6349
7066
  if (!readOnlyPeer(peer) && owner.permissionModeState !== "unmanaged" && owner.getPermissionMode() !== selected) {
6350
7067
  if (proxy.port && !proxy.port.attached) proxy.port.setStartupPermissionMode(selected);
6351
7068
  else await owner.setPermissionMode(selected);
7069
+ startCurrent?.();
7070
+ }
7071
+ if (isExternalInstance(peer)) {
7072
+ const launch = externalLaunches.get(peer), current = captureExternalBinding(peer);
7073
+ if (!membershipAllowed(peer) || !launch || launch.owner !== owner || !await verifyManagedAcpLaunch(owner, launch.options) || !sameExternalInstanceBinding(launch.binding, current)) return { ok: false, error: "external instance admission changed" };
7074
+ startCurrent?.();
7075
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || externalWorkspaceIdentity(current.executionCwd) !== launch.workspace || !sameExternalInstanceBinding(launch.binding, captureExternalBinding(peer))) return { ok: false, error: "external instance admission changed" };
7076
+ }
7077
+ // Roles may change while native startup/mode acknowledgement is awaited; the current workspace policy still owns admission.
7078
+ if (declarations[peer]) {
7079
+ try {
7080
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== executionRoots.get(peer)) return { ok: false, error: "instance execution policy changed during startup" };
7081
+ } catch { return { ok: false, error: "instance execution policy changed during startup" }; }
6352
7082
  }
6353
7083
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
7084
+ startCurrent?.();
6354
7085
  executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
6355
7086
  bus.add(owner);
6356
7087
  // start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
@@ -6365,6 +7096,7 @@ export async function startDaemon(opts: DaemonOptions) {
6365
7096
  let refusalRecorded=false;
6366
7097
  const reply = (body: Record<string, unknown>) => {
6367
7098
  const label=operationalRequestLabel(msg);
7099
+ if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) body = { ok: false, error: "external instance launch or execution root changed" };
6368
7100
  if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
6369
7101
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
6370
7102
  if(body.authorityRefused===true) {
@@ -6377,6 +7109,7 @@ export async function startDaemon(opts: DaemonOptions) {
6377
7109
  };
6378
7110
  if (!c.authed) {
6379
7111
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7112
+ if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
6380
7113
  if (msg.v !== PROTOCOL) {
6381
7114
  // An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
6382
7115
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
@@ -6394,6 +7127,7 @@ export async function startDaemon(opts: DaemonOptions) {
6394
7127
  // Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
6395
7128
  c.peer = String(msg.peer ?? "");
6396
7129
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
7130
+ if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
6397
7131
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
6398
7132
  if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
6399
7133
  const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
@@ -6402,6 +7136,14 @@ export async function startDaemon(opts: DaemonOptions) {
6402
7136
  c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
6403
7137
  if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
6404
7138
  }
7139
+ if (isExternalInstance(c.peer)) {
7140
+ const launch = externalLaunches.get(c.peer), current = currentExecutionOwner(c.peer);
7141
+ if (!launch || typeof msg.launchId !== "string" || msg.launchId !== launch.launchId || (!current && !starting.has(c.peer))) return sock.close(4403, "external instance launch or execution root is not current");
7142
+ // ACP may initialize its MCP server during session/new. Authenticate the pending launch,
7143
+ // but currentExternalTools fences every operation until the verified spawn joins the bus.
7144
+ c.externalOwner = { owner: launch.owner, launchId: launch.launchId, ...(launch.generation ? { generation: launch.generation } : {}) };
7145
+ if (current && !currentExternalTools(c)) return sock.close(4403, "external instance admission changed");
7146
+ }
6405
7147
  // #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
6406
7148
  // peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
6407
7149
  if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
@@ -6410,9 +7152,11 @@ export async function startDaemon(opts: DaemonOptions) {
6410
7152
  }
6411
7153
  } else {
6412
7154
  c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
7155
+ if (isExternalInstance(c.peer)) return sock.close(4403, "external module instances require their managed ACP launch");
6413
7156
  if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
6414
7157
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
6415
7158
  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");
7159
+ if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
6416
7160
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
6417
7161
  if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
6418
7162
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
@@ -6434,7 +7178,7 @@ export async function startDaemon(opts: DaemonOptions) {
6434
7178
  revokeTerminalExecution(c.peer);
6435
7179
  const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
6436
7180
  try {
6437
- const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
7181
+ const root = resolveExecutionCwd(c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
6438
7182
  if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
6439
7183
  executionRoots.set(c.peer, root);
6440
7184
  executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
@@ -6453,13 +7197,29 @@ export async function startDaemon(opts: DaemonOptions) {
6453
7197
  }
6454
7198
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
6455
7199
  }
6456
- if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
7200
+ const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
7201
+ && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
7202
+ if (stopping && !committedSourceProof && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
6457
7203
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
7204
+ if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
6458
7205
  if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
6459
7206
  switch (msg.t) {
7207
+ case "permission_failure_probe": {
7208
+ const launch = currentImplementerTools(c), entry = currentImplementerFailure(c, launch);
7209
+ if (!entry || ++entry.probeCount > NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) return void reply({ ok: false, error: "permission diagnostic unavailable" });
7210
+ // Validation input is temporary. No caller-selected stage, native id or body is retained or echoed.
7211
+ const probe = buildNativePermissionProbe(msg.request);
7212
+ if (!probe || validateNativePermissionRequest(probe.request)) return void reply({ ok: false, error: "permission diagnostic unavailable" });
7213
+ entry.permissionAttempt++;
7214
+ const recorded = entry.trace.record("invalid_permission_request");
7215
+ return void reply(recorded ? { ok: true } : { ok: false, error: "permission diagnostic unavailable" });
7216
+ }
6460
7217
  case "permission_request": {
6461
7218
  const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
6462
7219
  if (!peer || !launch || c.role !== "tools" || typeof msg.tool !== "string" || !msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)) return void reply({ ok: false, behavior: "deny", message: "permission unavailable" });
7220
+ const failure = currentImplementerFailure(c, launch), attempt = failure ? ++failure.permissionAttempt : 0;
7221
+ failure?.trace.permissionObserved();
7222
+ const failureCurrent = () => !!failure && failure.permissionAttempt === attempt && currentImplementerFailure(c) === failure;
6463
7223
  const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
6464
7224
  const pending = new AbortController(), abortPending = () => pending.abort();
6465
7225
  launch.signal.addEventListener("abort", abortPending, { once: true });
@@ -6468,10 +7228,13 @@ export async function startDaemon(opts: DaemonOptions) {
6468
7228
  const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
6469
7229
  c.toolsOwner?.owner.keepApprovalAlive?.();
6470
7230
  void (async () => {
6471
- const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current()) : undefined;
7231
+ const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current(), failure ? { entry: failure, attempt } : undefined) : undefined;
6472
7232
  if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
6473
7233
  if (!current()) return void reply({ ok: true, behavior: "deny", message: "permission denied" });
6474
- const result = await requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal);
7234
+ const result = await requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal, undefined, false, {
7235
+ registered: id => { if (failureCurrent()) failure!.trace.permissionObserved(id); },
7236
+ expired: id => { if (failureCurrent()) failure!.trace.record("manual_queue_expired", id); },
7237
+ });
6475
7238
  const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
6476
7239
  reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
6477
7240
  })().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
@@ -6479,12 +7242,12 @@ export async function startDaemon(opts: DaemonOptions) {
6479
7242
  }
6480
7243
  case "conductor_operational": {
6481
7244
  try {
6482
- if(!["authorize","logs","export","orphans"].includes(msg.op))return void reply({ok:false,error:"unknown operational request"});
6483
- const commands=["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"];
6484
- const command=msg.op==="authorize"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
6485
- if(!commands.includes(command))return void reply({ok:false,error:"unknown operational command"});
7245
+ if(!["authorize","admit","logs","export","orphans"].includes(msg.op))return void reply({ok:false,error:"unknown operational request"});
7246
+ const command=msg.op==="authorize"||msg.op==="admit"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
7247
+ // #933: `admit` is the one audited admission of a form whose effect runs in the CLI process; `authorize` only checks the role for a form whose own request admits.
7248
+ if(msg.op==="admit"?!conductorLocalOperation(command):!(CONDUCTOR_DAEMON_OPERATIONS as readonly string[]).includes(command))return void reply({ok:false,error:"unknown operational command"});
6486
7249
  const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
6487
- if(msg.op==="authorize")return void reply({ok:true,instanceId});
7250
+ if(msg.op==="authorize"||msg.op==="admit")return void reply({ok:true,instanceId});
6488
7251
  if(msg.op==="logs" || msg.op==="export") {
6489
7252
  const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
6490
7253
  if(!lines.available)return void reply({ok:false,error:"operational history unavailable"});
@@ -6537,14 +7300,15 @@ export async function startDaemon(opts: DaemonOptions) {
6537
7300
  }
6538
7301
  if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
6539
7302
  if (msg.op === "list") {
6540
- if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
7303
+ if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
6541
7304
  return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
6542
7305
  }
6543
7306
  if (msg.op === "discard") {
6544
7307
  // #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
6545
7308
  // through the same `resolveDelivery` as one by one, each with the reason, so the journal records each as a `resolve ... discard`. Push peers' queues are never touched
6546
7309
  // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
6547
- if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
7310
+ if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
7311
+ if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
6548
7312
  const state = msg.state === undefined ? "queued" : msg.state;
6549
7313
  if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
6550
7314
  const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
@@ -6558,7 +7322,9 @@ export async function startDaemon(opts: DaemonOptions) {
6558
7322
  const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
6559
7323
  try {
6560
7324
  if (!current || current.state !== state) throw new Error("unknown delivery");
6561
- bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
7325
+ bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
7326
+ if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
7327
+ discarded++;
6562
7328
  }
6563
7329
  catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
6564
7330
  }
@@ -6573,7 +7339,10 @@ export async function startDaemon(opts: DaemonOptions) {
6573
7339
  if (msg.op !== "resolve" || !["completed", "retry", "discard"].includes(msg.action) || !Number.isSafeInteger(msg.revision) || msg.revision < 0 || typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "resolution needs an action, observed revision and reason" });
6574
7340
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
6575
7341
  try {
7342
+ const before = bus.queueShow(msg.id);
7343
+ if (before?.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
6576
7344
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
7345
+ if (before?.peer.includes("@")) roomChat.disposition(before.peer, msg.id, msg.action);
6577
7346
  writeStatus();
6578
7347
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id),false,c.role!=="console") });
6579
7348
  } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
@@ -6645,9 +7414,131 @@ export async function startDaemon(opts: DaemonOptions) {
6645
7414
  return void reply({ ok: false, error: "unknown execution budget operation" });
6646
7415
  } catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6647
7416
  }
6648
- case "recovery":
6649
- if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
6650
- void recoveryOp(msg).then(reply, (error) => {
7417
+ case "recovery_restore": {
7418
+ try {
7419
+ const current = admitOperational(sock, "recovery restore"); current();
7420
+ const raw = msg.authority as ConductorRecoveryBinding;
7421
+ if (!raw || raw.peer !== c.peer || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir
7422
+ || !restored || restored.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId
7423
+ || recoveryPhase !== "restored" || recoveryOperationId !== raw.operationId || instanceId === raw.sourceInstanceId
7424
+ || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest) throw new RecoveryAuthorityError();
7425
+ const proof = authorizeConductorRecovery(opts.stateDir, raw, () => { current(); const value = currentRecoveryPolicy(c.peer!); return value.policyDigest === raw.policyDigest && value.boardDigest === raw.boardDigest; });
7426
+ const fence = () => { current(); assertConductorRecovery(proof); if (recoveryPhase !== "restored" || recoveryOperationId !== raw.operationId) throw new RecoveryAuthorityError(); };
7427
+ const saved = restored.peers.find(row => row.id === msg.peer);
7428
+ if (!saved || saved.state === "offline" || saved.launch?.mode === "tui" || definitionOf(saved.id)?.transport === "claude-channel" || !definitionOf(saved.id)?.session.restoreArgs) throw new RecoveryAuthorityError();
7429
+ // Host snapshot only. No argv, model, session or launch field supplied by the tools caller is used.
7430
+ const launch = saved.launch ?? {};
7431
+ const args: Parameters<typeof startPeer>[1] = { mode: "headless",
7432
+ ...(typeof launch.model === "string" ? { model: launch.model } : {}), ...(typeof launch.route === "string" ? { route: launch.route } : {}),
7433
+ ...(launch.backend === "auto" || launch.backend === "dgx" || launch.backend === "mlx" ? { backend: launch.backend } : {}),
7434
+ ...(saved.sessionId ? { sessionId: saved.sessionId } : {}), ...(typeof launch.sessionFile === "string" ? { sessionFile: launch.sessionFile } : {}),
7435
+ ...(typeof launch.providerProfile === "string" ? { providerProfile: launch.providerProfile } : {}) };
7436
+ fence();
7437
+ void startPeer(saved.id, args, undefined, fence).then(result => { try { fence(); reply(result); } catch { reply({ ok: false, error: "conductor recovery authority unavailable" }); } }, () => reply({ ok: false, error: "conductor recovery restore refused" }));
7438
+ } catch { reply({ ok: false, error: "conductor recovery authority unavailable; the person runs it in ahub console or a terminal" }); }
7439
+ return;
7440
+ }
7441
+ case "recovery_authority": {
7442
+ void (async () => { try {
7443
+ const settlingSource = msg.op === "verify" && recoveryCommitted && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
7444
+ const current = operationalCurrent(sock, false, settlingSource); current();
7445
+ if (msg.op === "authorize" && ["restart", "upgrade"].includes(msg.command)) { currentRecoveryPolicy(c.peer!); const inputs = readRecoveryPolicyInputs(opts.cwd, runningRoutingDefault); current(); return void reply({ ok: true, instanceId, ...(inputs.routingFallback === undefined ? {} : { routingDefault: inputs.routingFallback }) }); }
7446
+ if (!["issue", "verify"].includes(msg.op)) throw new RecoveryAuthorityError();
7447
+ const raw = msg.binding as ConductorRecoveryBinding;
7448
+ if (!raw || raw.peer !== c.peer || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir) throw new RecoveryAuthorityError();
7449
+ const project = { root: opts.cwd, id: projectId, stateDir: opts.stateDir };
7450
+ const expected = { ...raw, ...currentRecoveryPolicy(c.peer!) };
7451
+ const fresh = () => { current(); return JSON.stringify(currentRecoveryPolicy(c.peer!)) === JSON.stringify({ policyDigest: raw.policyDigest, boardDigest: raw.boardDigest }); };
7452
+ if (msg.op === "issue") {
7453
+ const plan = msg.plan as UpgradePlan;
7454
+ const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
7455
+ if (!plan || !["restart", "upgrade"].includes(plan.kind) || plan.projects.some(row => row.terminals.length > 0) || planFingerprint(body) !== fingerprint || fingerprint !== raw.planFingerprint || plan.kind !== raw.kind || existsSync(operationPath(raw.operationId))
7456
+ || raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock() || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
7457
+ if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
7458
+ if (plan.kind === "upgrade") {
7459
+ // Shared package/plugin effects cover every running or uncertain registered project, not a caller-selected subset.
7460
+ const affected = registeredProjects().filter(project => {
7461
+ const control = readControl(project.stateDir);
7462
+ return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
7463
+ });
7464
+ if (affected.length !== plan.projects.length || affected.some(project => !plan.projects.some(row => row.project.id === project.id && row.project.root === project.root && row.project.stateDir === project.stateDir))) throw new RecoveryAuthorityError();
7465
+ }
7466
+ const selected = plan.projects.find(row => row.project.id === projectId)!;
7467
+ const reconnect = new Map<string, import("../cli/upgrade.ts").RecoveryPeer["observation"]>();
7468
+ const reconnectCurrent: Array<() => boolean> = [];
7469
+ if (selected.reconnectOnly !== undefined) {
7470
+ if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
7471
+ for (const id of selected.reconnectOnly) {
7472
+ const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
7473
+ if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
7474
+ || Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
7475
+ || !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
7476
+ || row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
7477
+ const generation = owner.sessionGeneration;
7478
+ const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
7479
+ if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)) throw new RecoveryAuthorityError();
7480
+ reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
7481
+ && definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
7482
+ reconnect.set(id, row.observation);
7483
+ }
7484
+ }
7485
+ if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
7486
+ const normalize = (row: { id: string; state: string; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
7487
+ const launch = row.args ?? row.launch ?? {};
7488
+ const observed = reconnect.get(row.id);
7489
+ return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
7490
+ ...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
7491
+ ...(observed ? { kind: row.observation?.kind ?? definitionOf(row.id)?.kind, attached: row.observation?.attached ?? (isWsPeer(bus.peers.get(row.id)) && (bus.peers.get(row.id) as import("../adapters/channel-peer.ts").WsPeer).attached) } : {}),
7492
+ sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
7493
+ args: Object.fromEntries(Object.entries(launch).filter(([key, value]) => ["model", "route", "sessionFile", "mode", "backend"].includes(key) && typeof value === "string").sort(([a], [b]) => a.localeCompare(b))) };
7494
+ };
7495
+ const ordered = (rows: Parameters<typeof normalize>[0][]) => rows.map(normalize).sort((a, b) => a.id.localeCompare(b.id));
7496
+ if (JSON.stringify(ordered(selected.source.peers)) !== JSON.stringify(ordered(Object.values(recoveryPeers())))) throw new RecoveryAuthorityError();
7497
+ exactVersion(plan.version);
7498
+ if (verifyRecoveryTarget(raw.kind, raw.targetRoot, plan.version) !== raw.targetDigest) throw new RecoveryAuthorityError();
7499
+ readRecoveryTargetFeatures(raw.targetRoot);
7500
+ if (raw.kind === "restart") {
7501
+ if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
7502
+ } else {
7503
+ const staged = join(hubHome(), "releases", plan.version);
7504
+ if (raw.targetRoot !== join(staged, "node_modules/@staix/agent-hub")) throw new RecoveryAuthorityError();
7505
+ const verified = JSON.parse(readFileSync(join(staged, "verified.json"), "utf8"));
7506
+ if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
7507
+ }
7508
+ if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
7509
+ current();
7510
+ if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false })) throw new RecoveryAuthorityError();
7511
+ current();
7512
+ issueConductorRecoveryAuthority(opts.stateDir, expected, fresh);
7513
+ } else {
7514
+ if (instanceId !== raw.sourceInstanceId && (restored?.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest)) throw new RecoveryAuthorityError();
7515
+ assertConductorRecovery(authorizeConductorRecovery(opts.stateDir, expected, fresh));
7516
+ }
7517
+ current(); return void reply({ ok: true, instanceId, target: restored?.operationId === raw.operationId && instanceId !== raw.sourceInstanceId, operationId: recoveryOperationId, phase: recoveryPhase });
7518
+ } catch { return void reply({ ok: false, error: "conductor recovery authority unavailable; the person runs it in ahub console or a terminal" }); }
7519
+ })().catch(() => reply({ ok: false, error: "conductor recovery authority unavailable" }));
7520
+ return;
7521
+ }
7522
+ case "recovery": {
7523
+ let recoveryMessage = msg;
7524
+ let recoveryCurrent: () => void = () => {};
7525
+ if (c.role !== "console") {
7526
+ try {
7527
+ if (!["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) throw new RecoveryAuthorityError();
7528
+ const current = msg.op === "inspect" ? operationalCurrent(sock) : admitOperational(sock, `recovery ${msg.op}`); current();
7529
+ if (msg.op !== "inspect" || msg.operationId !== undefined) {
7530
+ const raw = msg.authority as ConductorRecoveryBinding;
7531
+ if (!raw || raw.peer !== c.peer || raw.operationId !== msg.operationId || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir) throw new RecoveryAuthorityError();
7532
+ if (instanceId !== raw.sourceInstanceId && (restored?.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest)) throw new RecoveryAuthorityError();
7533
+ const proof = authorizeConductorRecovery(opts.stateDir, raw, () => { current(); const value = currentRecoveryPolicy(c.peer!); return value.policyDigest === raw.policyDigest && value.boardDigest === raw.boardDigest; });
7534
+ recoveryCurrent = () => { current(); assertConductorRecovery(proof); if (recoveryTargetDigest(raw.kind, raw.targetRoot) !== raw.targetDigest) throw new RecoveryAuthorityError(); };
7535
+ recoveryCurrent();
7536
+ recoveryMessage = { ...msg, targetFeatures: readRecoveryTargetFeatures(raw.targetRoot) };
7537
+ } else recoveryCurrent = current;
7538
+ recoveryCurrent();
7539
+ } catch { return void reply(recoveryError("conductor recovery authority unavailable; the person runs it in ahub console or a terminal")); }
7540
+ }
7541
+ void recoveryOp(recoveryMessage, recoveryCurrent).then(reply, (error) => {
6651
7542
  if (isDatabaseBusy(error)) {
6652
7543
  try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
6653
7544
  const busy = new RecoveryDatabaseBusy();
@@ -6656,17 +7547,24 @@ export async function startDaemon(opts: DaemonOptions) {
6656
7547
  else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
6657
7548
  });
6658
7549
  return;
7550
+ }
6659
7551
  case "send": {
6660
7552
  if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
6661
7553
  // A human at the console should not wait out the batch window; agents default to status.
6662
7554
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
6663
7555
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
6664
- const to: PeerId[] | undefined = Array.isArray(msg.to) && msg.to.length ? [...new Set<string>(msg.to.map(String))] : undefined;
7556
+ const recipients = typeof msg.to === "string" && msg.to.length ? [msg.to] : msg.to;
7557
+ if (Object.hasOwn(msg, "to") && (!Array.isArray(recipients) || recipients.some(id => typeof id !== "string" || !id.length))) {
7558
+ return void reply({ t: "sent", ok: false, error: "to must be an array of peer ids" });
7559
+ }
7560
+ const to: PeerId[] | undefined = Array.isArray(recipients) && recipients.length ? [...new Set<string>(recipients)] : undefined;
6665
7561
  const unknown = to?.filter((id) => !bus.knownPeers().includes(id) && membershipAllowed(id)) ?? [];
6666
7562
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
6667
7563
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
6668
7564
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
6669
7565
  let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
7566
+ try { bus.checkRemotePublication(env); }
7567
+ catch (error) { return void reply({ t: "sent", ok: false, error: error instanceof Error && /^(?:room|remote) /.test(error.message) ? sanitize(error.message).slice(0, 8192) : "message admission unavailable" }); }
6670
7568
  const offRoster = bus.offRosterRecipients(env);
6671
7569
  if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) return void reply({ t: "sent", ok: false, error: `off roster: ${offRoster.join(", ")}` });
6672
7570
  const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
@@ -6676,7 +7574,10 @@ export async function startDaemon(opts: DaemonOptions) {
6676
7574
  const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
6677
7575
  const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
6678
7576
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
6679
- const { targets, dropped } = bus.publishResult(env);
7577
+ let published: ReturnType<Bus["publishResult"]>;
7578
+ try { published = bus.publishResult(env); }
7579
+ catch (error) { return void reply({ t: "sent", ok: false, error: error instanceof Error && /^(?:room|remote) /.test(error.message) ? sanitize(error.message).slice(0, 8192) : "message admission unavailable" }); }
7580
+ const { targets, dropped } = published;
6680
7581
  if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
6681
7582
  if (dropped === "hop") return void reply({ t: "sent", ok: true, targets, dropped, recorded: priority === "fyi",
6682
7583
  notice: `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to` });
@@ -6789,13 +7690,13 @@ export async function startDaemon(opts: DaemonOptions) {
6789
7690
  const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
6790
7691
  return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
6791
7692
  }
6792
- let nativeClaude = false;
7693
+ let nativeClaude = false, nativeTranscript: string | undefined;
6793
7694
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
6794
7695
  const expectedLaunch = claudeLaunchProof.expectedLaunch(peer);
6795
7696
  const current = claudeSession(peer);
6796
7697
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
6797
7698
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
6798
- const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
7699
+ const transcript = nativeTranscript = claudeTranscript(sessionId, msg.transcriptPath, peer);
6799
7700
  const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
6800
7701
  const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
6801
7702
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
@@ -6805,6 +7706,9 @@ export async function startDaemon(opts: DaemonOptions) {
6805
7706
  if (!nativeClaude) return void reply({ t: "facts", ok: false });
6806
7707
  }
6807
7708
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
7709
+ if (nativeClaude && (phase === "pre" || phase === "stop")) {
7710
+ try { observeClaudeDoing(peer, sessionId!, nativeTranscript, phase === "pre" ? tool : "", input); } catch { /* #878: a display value never costs the hook its answer */ }
7711
+ }
6808
7712
  const nativePeer = nativeClaude ? claudeLaunchProof.owner(peer) : undefined;
6809
7713
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudeLaunchProof.permissionLaunch(peer) : undefined;
6810
7714
  const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
@@ -6814,7 +7718,10 @@ export async function startDaemon(opts: DaemonOptions) {
6814
7718
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
6815
7719
  // Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
6816
7720
  const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
6817
- const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {};
7721
+ const plannerOnly = !!declarations[peer] && definitionOf(peer)?.transport === "claude-channel" && isPlannerOnly(config.roles[peer] ?? []);
7722
+ // Older hooks do not know mutationGrants; the existing permission field must also withhold their mutation grant.
7723
+ const plannerMutation = plannerOnly && !["Read", "LS", "Glob", "Grep"].includes(tool);
7724
+ const permissionReply = phase === "pre" && permission !== undefined ? { permission: plannerMutation || permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(plannerOnly ? { mutationGrants: false } : {}), ...(actionClasses.length ? { actionClasses } : {}) } : {};
6818
7725
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
6819
7726
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
6820
7727
  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")));
@@ -7024,7 +7931,7 @@ export async function startDaemon(opts: DaemonOptions) {
7024
7931
  if (tasks.isPii(now)) return { id: now.id, title: tasks.publicTitle(now), private: true };
7025
7932
  if (task.history.length !== now.history.length || !statuses.has(task.id)) throw new Error("task changed");
7026
7933
  const status = statuses.get(task.id)!;
7027
- const safeStatus = Object.fromEntries(["mode", "stage", "blockers", "ready", "actor", "next", "spec", "playbook", "criteria", "definition", "entry", "exit", "planConformance", "testIntegrity", "findings"].filter(key => key in status).map(key => [key, publicReadValue(status[key as keyof typeof status])]));
7934
+ const safeStatus = Object.fromEntries(["mode", "stage", "blockers", "ready", "actor", "next", "spec", "playbook", "criteria", "definition", "entry", "exit", "planConformance", "testIntegrity", "evalGate", "findings"].filter(key => key in status).map(key => [key, publicReadValue(status[key as keyof typeof status])]));
7028
7935
  return { id: now.id, title: tasks.publicTitle(now), receipt: publicReadValue(tasks.publicView(now).sdlc), ...safeStatus };
7029
7936
  });
7030
7937
  current(); reply({ ok: true, public: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
@@ -7086,8 +7993,70 @@ export async function startDaemon(opts: DaemonOptions) {
7086
7993
  })().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
7087
7994
  return;
7088
7995
  }
7996
+ case "room": {
7997
+ const client = sock.data;
7998
+ const personRoom = c.role === "console";
7999
+ const roomCurrent = () => !stopping && !recoveryActive() && !bus.isRecoveryHeld
8000
+ && sock.data === client && sock.readyState === WebSocket.OPEN && personRoom;
8001
+ const roomFailure = () => reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
8002
+ if (msg.op === "list" || msg.op === "status") {
8003
+ if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
8004
+ || Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
8005
+ void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }), roomFailure);
8006
+ return;
8007
+ }
8008
+ if (["inbox_list", "inbox_show", "inbox_done", "inbox_discard"].includes(msg.op)) {
8009
+ if (!personRoom) return void reply({ ok: false, error: "room inbox is person-only" });
8010
+ if (!roomCurrent() || Object.keys(msg).some(key => !["t", "op", "input", "rid"].includes(key))) return roomFailure();
8011
+ const input = msg.input;
8012
+ if (!input || typeof input !== "object" || Array.isArray(input)) return roomFailure();
8013
+ if (msg.op === "inbox_list") {
8014
+ if (Object.keys(input).some(key => key !== "name") || (input.name !== undefined && typeof input.name !== "string")) return roomFailure();
8015
+ void roomCommands.inboxRows(input.name, roomCurrent).then(inbox => {
8016
+ if (!roomCurrent()) return roomFailure();
8017
+ reply({ ok: true, inbox });
8018
+ }, error => {
8019
+ if (!roomCurrent()) return roomFailure();
8020
+ if (error instanceof RoomStoreError && error.code === "inbox_list_limit") return reply({ ok: false, code: "inbox_list_limit" });
8021
+ roomFailure();
8022
+ });
8023
+ return;
8024
+ }
8025
+ if (Object.keys(input).length !== 1 || typeof input.id !== "string" || !Object.hasOwn(input, "id")) return roomFailure();
8026
+ const operation = msg.op === "inbox_show" ? roomCommands.inboxShow(input.id, roomCurrent)
8027
+ : roomCommands.inboxDispose(input.id, msg.op === "inbox_done" ? "done" : "discard", roomCurrent);
8028
+ void operation.then(item => {
8029
+ if (!roomCurrent()) return roomFailure();
8030
+ if (!item) return reply({ ok: false, code: "inbox_not_found" });
8031
+ reply({ ok: true, item });
8032
+ if (msg.op !== "inbox_show") roomCommands.retryInboxPending();
8033
+ }, roomFailure);
8034
+ return;
8035
+ }
8036
+ if (!personRoom) return void reply({ ok: false, error: "room mutations are person-only" });
8037
+ if (!roomCurrent()) return roomFailure();
8038
+ if (msg.op === "preview") {
8039
+ roomTickets.delete(sock);
8040
+ void roomCommands.preview(msg.action, msg.input, roomCurrent).then(preview => {
8041
+ if (!roomCurrent()) return roomFailure();
8042
+ const ticket = crypto.randomUUID();
8043
+ roomTickets.set(sock, { id: ticket, expires: Date.now() + 30_000, preview });
8044
+ reply({ ok: true, owner: preview.owner, member: preview.member, ticket });
8045
+ }, roomFailure);
8046
+ return;
8047
+ }
8048
+ if (msg.op === "execute") {
8049
+ const ticket = roomTickets.get(sock); roomTickets.delete(sock);
8050
+ if (!ticket || msg.ticket !== ticket.id || Date.now() >= ticket.expires
8051
+ || typeof msg.confirm !== "string" || !/^[0-9a-f]{16,64}$/.test(msg.confirm) || !ticket.preview.member.startsWith(msg.confirm)
8052
+ || Object.keys(msg).some(key => !["t", "op", "ticket", "confirm", "rid"].includes(key))) return roomFailure();
8053
+ void ticket.preview.commit().then(result => reply({ ok: true, ...result }), roomFailure);
8054
+ return;
8055
+ }
8056
+ return void reply({ ok: false, error: "unknown room operation" });
8057
+ }
7089
8058
  case "status":
7090
- return void reply({ t: "status", status: status(c.role === "console") });
8059
+ return void reply({ t: "status", status: status(c.role === "console", false, true) });
7091
8060
  case "cli_audit_ack": {
7092
8061
  if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
7093
8062
  if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
@@ -7108,9 +8077,10 @@ export async function startDaemon(opts: DaemonOptions) {
7108
8077
  void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
7109
8078
  return;
7110
8079
  case "task_catalog_role":
7111
- if (c.role !== "console") return void reply({ ok: false, error: "task roles are a person command" });
8080
+ // #933: an agent changes its own task's role (Tasks.setRole refuses another peer's task); a child, read-only or private context never does.
8081
+ if (c.role !== "console" && (c.role !== "tools" || !c.peer || c.toolsOwner || c.externalOwner || readOnlyPeer(c.peer) || contextPii(c.peer))) return void reply({ ok: false, error: "task roles are a person command" });
7112
8082
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding task role changes" });
7113
- try { const task = tasks.setRole(USER, msg.id, msg.role); return void reply({ ok: true, text: `task #${task.id}: catalog role ${task.role ?? "none"}` }); }
8083
+ try { const task = tasks.setRole(c.role === "console" ? USER : c.peer!, msg.id, msg.role); return void reply({ ok: true, text: `task #${task.id}: catalog role ${task.role ?? "none"}` }); }
7114
8084
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
7115
8085
  case "settings_get":
7116
8086
  if (c.role !== "console") {
@@ -7173,7 +8143,7 @@ export async function startDaemon(opts: DaemonOptions) {
7173
8143
  case "peer_start":
7174
8144
  // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
7175
8145
  if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
7176
- void hubStart(msg.peer, "the console").then(result => reply(includeRetainedLaunchConfig(String(msg.peer), result)), (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
8146
+ void hubStart(msg.peer, "the console", true).then(result => reply(includeRetainedLaunchConfig(String(msg.peer), result)), (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
7177
8147
  return;
7178
8148
  case "peer_stop":
7179
8149
  let stopCurrent:(()=>void)|undefined;
@@ -7192,13 +8162,34 @@ export async function startDaemon(opts: DaemonOptions) {
7192
8162
  });
7193
8163
  return;
7194
8164
  case "task":
8165
+ if (c.role === "tools" && msg.op === "task_unassign") {
8166
+ if (conductorPeer(config.roles) !== c.peer) return void reply({ t: "task", ok: false, error: "task_unassign is a console command" });
8167
+ try {
8168
+ const current = admitOperational(sock, "task unassign");
8169
+ void childToolAuthority.run({ by: c.peer!, current: () => { current(); return true; } },
8170
+ () => taskOp(c.peer!, "task_unassign", msg.args ?? {})).then(
8171
+ text => reply({ t: "task", ok: true, text }),
8172
+ error => reply({ t: "task", ok: false, error: error instanceof Error ? error.message : "unassign refused" }));
8173
+ } catch (error) { reply({ t: "task", ok: false, error: error instanceof Error ? error.message : "unassign refused" }); }
8174
+ return;
8175
+ }
7195
8176
  if (c.role === "tools" && msg.op === "release_show") {
7196
8177
  try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
7197
8178
  catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
7198
8179
  }
7199
8180
  if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
7200
8181
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
7201
- (c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
8182
+ if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
8183
+ // #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
8184
+ let current: () => void;
8185
+ try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
8186
+ void operationalContext.run({ actor: c.peer, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})).then(
8187
+ (text) => reply({ t: "task", ok: true, text }),
8188
+ (e: Error) => reply({ t: "task", ...operationalFailure(e) }),
8189
+ );
8190
+ return;
8191
+ }
8192
+ (c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
7202
8193
  (text) => reply({ t: "task", ok: true, text }),
7203
8194
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
7204
8195
  );
@@ -7211,23 +8202,41 @@ export async function startDaemon(opts: DaemonOptions) {
7211
8202
  return;
7212
8203
  }
7213
8204
  case "goal_link":
7214
- // #258: a person links a board task to a long-term task (`ahub goal link`); the link goes through Tasks.
7215
- if (c.role !== "console") return void reply({ t: msg.t, ok: false, error: "goal_link is a console command" });
8205
+ // #258: a person links a board task to a long-term task (`ahub goal link`); the link goes through Tasks. #933: the configured conductor too, admitted and audited here.
8206
+ if (c.role !== "console" && c.role !== "tools") return void reply({ t: msg.t, ok: false, error: "goal_link is a console command" });
7216
8207
  try {
7217
- const text = tasks.linkGoal(USER, msg.task, msg.goal);
8208
+ if (c.role === "tools") admitOperational(sock, "goal link");
8209
+ const text = tasks.linkGoal(c.role === "console" ? USER : c.peer!, msg.task, msg.goal);
7218
8210
  const linked = board.get(Number(msg.task));
7219
8211
  if (linked?.state === "in_progress" && !tasks.isPii(linked)) renewLeases(linked); // already being worked on: hold it now
7220
8212
  return void reply({ t: msg.t, ok: true, text });
7221
8213
  }
7222
- catch (e) { return void reply({ t: msg.t, ok: false, error: (e as Error).message }); }
7223
- case "ask":
8214
+ catch (e) { return void reply({ t: msg.t, ...operationalFailure(e) }); }
8215
+ case "ask": {
7224
8216
  // Console only: the evidence may hold PII task text (on campus), and the answer is for the person at the terminal.
7225
8217
  if (c.role !== "console") return void reply({ t: "ask", ok: false, error: "ask is a console command" });
8218
+ const client = sock.data, revision = configRevision, policy = corpusPolicyVersion();
8219
+ const callerPrivate = () => !!client.peer && contextPii(client.peer);
8220
+ const initialPrivate = callerPrivate();
8221
+ const isPiiText = (text: string) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: text, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii");
8222
+ let evidencePrivacy: readonly TaskEvidencePrivacy[] = [];
8223
+ const askCurrent = () => !stopping && sock.data === client && sock.readyState === WebSocket.OPEN
8224
+ && configRevision === revision && corpusPolicyVersion() === policy && callerPrivate() === initialPrivate
8225
+ && evidencePrivacy.every(previous => {
8226
+ const latest = board.get(previous.id);
8227
+ if (!latest || latest.created !== previous.created) return false;
8228
+ const privacy = taskEvidencePrivacy(latest, { isPii: task => tasks.isPii(task), isPiiText });
8229
+ return privacy.pii === previous.pii && privacy.notePii === previous.notePii;
8230
+ });
7226
8231
  ask(String(msg.question ?? ""), {
7227
8232
  board,
7228
8233
  isPii: (t) => tasks.isPii(t),
7229
8234
  onCampus,
7230
- isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
8235
+ current: askCurrent,
8236
+ privateContext: callerPrivate,
8237
+ bindTaskEvidence: rows => { evidencePrivacy = rows; },
8238
+ corpus: keys => publicCorpus(keys.join(" "), askCurrent),
8239
+ isPiiText,
7231
8240
  ...(config.memory.enabled ? { memory } : {}),
7232
8241
  // #258: entries that match the question, then the graph neighbourhood of what they are about.
7233
8242
  ...(memoryStore ? { native: (keys: string[]) => {
@@ -7243,6 +8252,7 @@ export async function startDaemon(opts: DaemonOptions) {
7243
8252
  ...(inference ? { inference } : {}),
7244
8253
  })
7245
8254
  .then(async (res) => {
8255
+ if (!askCurrent()) return void reply({ t: "ask", ok: true, found: false, evidence: [], pii: true, note: "evidence context changed" });
7246
8256
  let saved: string | undefined;
7247
8257
  if (msg.remember) {
7248
8258
  if (res.pii) saved = "not saved: PII is involved";
@@ -7263,6 +8273,7 @@ export async function startDaemon(opts: DaemonOptions) {
7263
8273
  })
7264
8274
  .catch((e: Error) => reply({ t: "ask", ok: false, error: e.message }));
7265
8275
  return;
8276
+ }
7266
8277
  case "budget":
7267
8278
  if (c.role !== "console") {
7268
8279
  if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
@@ -7339,6 +8350,8 @@ export async function startDaemon(opts: DaemonOptions) {
7339
8350
  }
7340
8351
  async function stopOnce(): Promise<void> {
7341
8352
  stopping = true;
8353
+ roomCommands.stop();
8354
+ tasks.stopQueuePressureRechecks();
7342
8355
  let implementerShellCleanupFailed = false;
7343
8356
  const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
7344
8357
  terminalLaunches.clear();
@@ -7355,11 +8368,19 @@ export async function startDaemon(opts: DaemonOptions) {
7355
8368
  dashboard?.stop();
7356
8369
  stopConfigWatch();
7357
8370
  clearInterval(sweepTimer);
8371
+ clearInterval(schedulesTimer);
7358
8372
  tasks.stopCiWatchers(); // #785 P1b: no per-head CI watcher outlives the hub
7359
8373
  for (const i of intervals) clearInterval(i);
7360
8374
  for (const pending of checkpointWaits.values()) pending.done(undefined);
7361
8375
  for (const p of permissions.values()) p.done(undefined);
7362
8376
  try {
8377
+ // A schedules pass may be inside its PII screen: `stopping` is set, so its fence refuses the write when the screen returns. Wait for it (bounded, the screen has
8378
+ // its own timeout) before the peers stop, the board closes and the store closes, so no pass runs against a closed database.
8379
+ if (schedulesPass || manualSchedulePasses.size) {
8380
+ let timer: ReturnType<typeof setTimeout> | undefined;
8381
+ await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
8382
+ clearTimeout(timer);
8383
+ }
7363
8384
  await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
7364
8385
  await terminalLaunches.settled();
7365
8386
  // A peer can still be inside memory recall or its native handshake when kill arrives.
@@ -7377,10 +8398,13 @@ export async function startDaemon(opts: DaemonOptions) {
7377
8398
  await runnerStopped;
7378
8399
  await Promise.all([...modelRelays.values()].map(relay => relay.close()));
7379
8400
  await egress?.close();
8401
+ await webEgress?.close();
7380
8402
  await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
7381
8403
  collectClaudeUsage();
7382
8404
  for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
7383
8405
  budget.close();
8406
+ schedulesStore.close();
8407
+ folderStore.close();
7384
8408
  conductorHolds.close();
7385
8409
  executionBudget.close();
7386
8410
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
@@ -7424,7 +8448,8 @@ export async function startDaemon(opts: DaemonOptions) {
7424
8448
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
7425
8449
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
7426
8450
  ready = true;
7427
- for (const name of CONFIG_SOURCE_FILES) {
8451
+ tasks.scheduleQueuePressureRecheck();
8452
+ for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
7428
8453
  const path = join(opts.cwd, ".agenthub", name);
7429
8454
  const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
7430
8455
  if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
@@ -7465,7 +8490,8 @@ export async function startDaemon(opts: DaemonOptions) {
7465
8490
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
7466
8491
  // #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
7467
8492
  void sweepAgentDrift();
7468
- return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
8493
+ // #515b: the loopback ports of the two egress proxies, for a host-only test that connects to the proxy this daemon actually started. Ports only: no list, no authority.
8494
+ return { bus, token, port: server.port as number, egressPorts: () => ({ bash: egress?.port, web: webEgress?.port }), bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
7469
8495
  } finally {
7470
8496
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
7471
8497
  }