@staix/agent-hub 0.20.0 → 0.21.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 (175) hide show
  1. package/CHANGELOG.md +59 -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 +2 -2
  8. package/docs/agent-notes/daemon.md +15 -6
  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 +37 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +565 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +14 -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 +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1070 -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/claude-implementer-permission-triage.ts +160 -0
  37. package/docs/verification/claude-permission-transport-native.py +71 -14
  38. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  39. package/docs/verified.json +15 -15
  40. package/package.json +2 -2
  41. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  42. package/plugins/agent-hub/server.js +1016 -280
  43. package/src/adapters/acp.ts +79 -3
  44. package/src/adapters/channel-peer.ts +4 -0
  45. package/src/adapters/claude-channel.ts +13 -6
  46. package/src/adapters/claude-implementer.ts +60 -7
  47. package/src/adapters/codex-appserver.ts +77 -4
  48. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  49. package/src/adapters/conformance/kit.ts +3 -9
  50. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  51. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  52. package/src/adapters/definitions/native-config-policy.ts +3 -2
  53. package/src/adapters/definitions/native-context.ts +12 -0
  54. package/src/adapters/external-metadata.ts +48 -0
  55. package/src/adapters/external-modules.ts +63 -3
  56. package/src/adapters/jcode.ts +6 -2
  57. package/src/adapters/local-worker.ts +40 -25
  58. package/src/adapters/native-proof-ports.ts +10 -0
  59. package/src/adapters/registry-types.ts +4 -0
  60. package/src/broker/protocol.ts +203 -0
  61. package/src/broker/server.ts +314 -0
  62. package/src/broker/store.ts +548 -0
  63. package/src/cli/bench.ts +3 -2
  64. package/src/cli/broker.ts +58 -0
  65. package/src/cli/conductor-recovery.ts +142 -0
  66. package/src/cli/configured-acp-entry.ts +26 -6
  67. package/src/cli/console-state.ts +59 -17
  68. package/src/cli/console.ts +17 -2
  69. package/src/cli/facts-hook.ts +3 -1
  70. package/src/cli/help.ts +28 -16
  71. package/src/cli/identity.ts +23 -10
  72. package/src/cli/init.ts +34 -7
  73. package/src/cli/intake-output.ts +33 -0
  74. package/src/cli/main.ts +314 -71
  75. package/src/cli/output.ts +41 -1
  76. package/src/cli/recovery-package.ts +18 -1
  77. package/src/cli/role-holders.ts +35 -0
  78. package/src/cli/room.ts +304 -0
  79. package/src/cli/say-input.ts +16 -0
  80. package/src/cli/schedule-output.ts +49 -0
  81. package/src/cli/sdlc-output.ts +44 -6
  82. package/src/cli/smoke-pty-bridge.ts +33 -0
  83. package/src/cli/smoke-pty.ts +145 -0
  84. package/src/cli/smoke-run.ts +139 -0
  85. package/src/cli/smoke-target.ts +130 -0
  86. package/src/cli/source-output.ts +29 -0
  87. package/src/cli/task-output.ts +3 -2
  88. package/src/cli/upgrade-runtime.ts +98 -16
  89. package/src/cli/upgrade.ts +66 -7
  90. package/src/hub/ask.ts +76 -12
  91. package/src/hub/board.ts +132 -22
  92. package/src/hub/bus.ts +399 -56
  93. package/src/hub/child-process.ts +2 -0
  94. package/src/hub/ci-watch.ts +42 -20
  95. package/src/hub/claude-failure-stage.ts +85 -0
  96. package/src/hub/claude-shell-wrapper.ts +11 -5
  97. package/src/hub/conductor.ts +17 -4
  98. package/src/hub/config-apply.ts +32 -5
  99. package/src/hub/config-reload.ts +7 -5
  100. package/src/hub/config-trust.ts +7 -1
  101. package/src/hub/context-window.ts +33 -3
  102. package/src/hub/control-client.ts +14 -2
  103. package/src/hub/corpus-config.ts +34 -0
  104. package/src/hub/corpus-evidence.ts +184 -0
  105. package/src/hub/corpus.ts +373 -0
  106. package/src/hub/cost-governance.ts +26 -0
  107. package/src/hub/daemon.ts +1150 -171
  108. package/src/hub/deliverable-files.ts +8 -2
  109. package/src/hub/delivery-journal.ts +123 -17
  110. package/src/hub/dogfood-profile.ts +32 -5
  111. package/src/hub/dogfood-read.ts +6 -2
  112. package/src/hub/envelope.ts +11 -0
  113. package/src/hub/eval-gate.ts +131 -0
  114. package/src/hub/events.ts +1 -1
  115. package/src/hub/external-instance.ts +69 -0
  116. package/src/hub/facts.ts +15 -5
  117. package/src/hub/folder-trigger.ts +223 -0
  118. package/src/hub/hub-tools.ts +2 -1
  119. package/src/hub/inference.ts +20 -5
  120. package/src/hub/instance-config.ts +69 -3
  121. package/src/hub/instance-execution.ts +6 -2
  122. package/src/hub/instances.ts +32 -4
  123. package/src/hub/intake.ts +125 -0
  124. package/src/hub/learning.ts +85 -0
  125. package/src/hub/lifecycle.ts +7 -3
  126. package/src/hub/limits.ts +12 -5
  127. package/src/hub/native-permission-request.ts +77 -0
  128. package/src/hub/operational-authority.ts +63 -5
  129. package/src/hub/operational-settings.ts +76 -0
  130. package/src/hub/operational-view.ts +1 -1
  131. package/src/hub/packs.ts +132 -0
  132. package/src/hub/path-policy.ts +7 -0
  133. package/src/hub/peer-doing.ts +130 -0
  134. package/src/hub/peers.ts +12 -0
  135. package/src/hub/queue-backpressure.ts +58 -0
  136. package/src/hub/read-tools.ts +2 -0
  137. package/src/hub/recovery-authority.ts +151 -0
  138. package/src/hub/recovery-policy.ts +220 -0
  139. package/src/hub/research.ts +24 -14
  140. package/src/hub/restart.ts +7 -0
  141. package/src/hub/routing.ts +54 -11
  142. package/src/hub/schedules.ts +266 -0
  143. package/src/hub/sdlc-merge.ts +38 -14
  144. package/src/hub/sdlc-metrics.ts +32 -10
  145. package/src/hub/sdlc-queue.ts +7 -1
  146. package/src/hub/sdlc-release.ts +1 -1
  147. package/src/hub/sdlc.ts +37 -7
  148. package/src/hub/security-baseline.ts +38 -0
  149. package/src/hub/settings.ts +6 -1
  150. package/src/hub/shipped-packs.ts +8 -0
  151. package/src/hub/snapshots.ts +72 -6
  152. package/src/hub/source-read.ts +160 -0
  153. package/src/hub/sources.ts +311 -0
  154. package/src/hub/tasks.ts +910 -110
  155. package/src/hub/workflow.ts +1 -1
  156. package/src/local/doc-read.ts +69 -0
  157. package/src/local/proxy.ts +4 -13
  158. package/src/local/tools.ts +37 -3
  159. package/src/local/web-fetch-config.ts +43 -0
  160. package/src/local/web-fetch.ts +183 -0
  161. package/src/memory/brief.ts +85 -13
  162. package/src/memory/capture.ts +1 -1
  163. package/src/rooms/application.ts +114 -0
  164. package/src/rooms/boundary.ts +103 -0
  165. package/src/rooms/commands.ts +294 -0
  166. package/src/rooms/crypto.ts +371 -0
  167. package/src/rooms/link.ts +584 -0
  168. package/src/rooms/policy.ts +82 -0
  169. package/src/rooms/remote-peer.ts +80 -0
  170. package/src/rooms/service.ts +426 -0
  171. package/src/rooms/status.ts +146 -0
  172. package/src/rooms/store.ts +1210 -0
  173. package/src/rooms/wire.ts +465 -0
  174. package/src/ui/index.html +91 -4
  175. 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,21 @@ 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 { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
85
+ import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
86
+ import type { ScannedFile } from "./schedules.ts";
87
+ import { corpusConfig } from "./corpus-config.ts";
73
88
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
74
- import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
89
+ import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
75
90
  import { resolveSettledRoles } from "./settled-roles.ts";
76
- import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
91
+ import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
92
+ import { issueConductorRecoveryAuthority, authorizeConductorRecovery, assertConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "./recovery-authority.ts";
93
+ import { captureRecoveryPolicy, readRecoveryPolicyInputs, recoveryRoutingFallback, assertRecoveryConfiguration, assertRecoveryRouting, assertRecoverySourceInputs, readRecoveryTargetFeatures } from "./recovery-policy.ts";
94
+ import { operationPath, recoveryLock, readOperation } from "./recovery-store.ts";
95
+ import { packageDigest, recoveryTargetDigest, verifyRecoveryTarget, exactVersion } from "../cli/recovery-package.ts";
96
+ import { planFingerprint, registeredProjects, type UpgradePlan } from "../cli/upgrade.ts";
97
+ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
77
98
  import { createConfigEditor } from "./config-edit-host.ts";
78
99
  import { readEditable } from "./config-edit.ts";
79
100
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
@@ -84,7 +105,7 @@ import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
84
105
  import { ExecutionBudget } from "./execution-budget.ts";
85
106
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
86
107
  import type { ServerWebSocket } from "bun";
87
- import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
108
+ import { isAcpPeer, managedAcpLaunch, verifyManagedAcpLaunch, type AcpOptions, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
88
109
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
89
110
  import { isPiPeer, PiVersionRefusal, type PiPeer } from "../adapters/pi.ts";
90
111
  import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
@@ -92,17 +113,18 @@ import { startModelRelay, type ModelRelay } from "../models/relay.ts";
92
113
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
93
114
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
94
115
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
95
- import { processSignature } from "../pi/process-signature.ts";
116
+ import { processSignature, processLiveness } from "../pi/process-signature.ts";
96
117
  import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
97
118
  import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
98
119
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
120
+ import { webFetchAllow } from "../local/web-fetch-config.ts";
99
121
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext, type ShellPermitPolicy } from "../local/tools.ts";
100
122
  import { proveReadOnlyShell, ShellExecutionProofs } from "./read-only-shell-proof.ts";
101
123
  import { createHostShellWrapper, type HostShellWrapper } from "./claude-shell-wrapper.ts";
102
124
  import { readTaskShellDefaultBranch, readTaskShellBinding, proveTaskShell } from "./task-shell-proof.ts";
103
125
  import { taskShellSandboxRestrictions } from "./task-shell-sandbox.ts";
104
126
  import { hostShellEnvironment } from "../local/shell-environment.ts";
105
- import { hubGitArgv, hubGitEnv } from "./git.ts";
127
+ import { hubGitArgv, hubGitEnv, hubGitSync } from "./git.ts";
106
128
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
107
129
  import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
108
130
  import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
@@ -110,7 +132,9 @@ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
110
132
  import { reviewDiff } from "../adapters/claude-print.ts";
111
133
  import { Capture } from "../memory/capture.ts";
112
134
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
113
- import { Briefs } from "../memory/brief.ts";
135
+ import { CorpusIndex } from "./corpus.ts";
136
+ import { corpusEvidence } from "./corpus-evidence.ts";
137
+ import { Briefs, type BriefTask } from "../memory/brief.ts";
114
138
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
115
139
  import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
116
140
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
@@ -123,13 +147,13 @@ import { realPath, hubHome } from "./project.ts";
123
147
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
124
148
  import type { BusEvent, PublishResult } from "./bus.ts";
125
149
  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";
150
+ import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, taskShowAllowed, type ConductEvent } from "./conductor.ts";
127
151
  import { SupervisionFeed } from "./supervision.ts";
128
152
  import { drainCliAudits } from "../cli/identity-audit.ts";
129
153
  import { launcherPreview } from "../cli/preview.ts";
130
154
  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";
155
+ import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig, type PiiVerdict } from "./inference.ts";
156
+ import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
133
157
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
134
158
  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";
135
159
  import { githubOriginRepository } from "./task-repository.ts";
@@ -141,6 +165,7 @@ import { codeGroup } from "./task-code-group.ts";
141
165
  import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
142
166
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
143
167
  import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
168
+ import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
144
169
  import { MemoryClient } from "../memory/client.ts";
145
170
  import { VERSION } from "../version.ts";
146
171
  import { appendRecords, taskRecords } from "./research.ts";
@@ -152,10 +177,10 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
152
177
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
153
178
  import type { JournalDelivery } from "./delivery-journal.ts";
154
179
  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";
180
+ 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
181
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
157
182
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
158
- import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
183
+ import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
159
184
  import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
160
185
 
161
186
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
@@ -169,7 +194,8 @@ import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type Can
169
194
  import { memoryProjectKey } from "@staix/agent-mem";
170
195
  import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
171
196
  import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
172
- import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
197
+ import { SourceRegistry } from "./sources.ts";
198
+ import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
173
199
  import { nativeRecall } from "../memory/recall.ts";
174
200
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
175
201
 
@@ -204,6 +230,8 @@ export interface HubConfig {
204
230
  pii?: { paths: string[] };
205
231
  data?: { readonly: string[] };
206
232
  text_projection?: ProjectionConfig;
233
+ /** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
234
+ corpus: { paths: string[] };
207
235
  catalog_roles?: Record<string, string[]>;
208
236
  conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
209
237
  budget: BudgetConfig;
@@ -213,13 +241,17 @@ export interface HubConfig {
213
241
  pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
214
242
  mlx: { enabled: boolean } & Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
215
243
  /** `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[] };
244
+ 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
245
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
218
246
  approvals: { timeout_s: number; notify: boolean };
219
247
  /** 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 };
248
+ 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
249
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
222
250
  task_sweep: TaskSweepConfig;
251
+ /** #512a: machine-local schedules; checked by `schedulesConfig`, never set from a committed file. */
252
+ schedules: ScheduleEntry[];
253
+ /** #512c: machine-local folder triggers, polled on the schedules timer; checked by `foldersConfig`, never set from a committed file. */
254
+ folder_triggers: FolderEntry[];
223
255
  /** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
224
256
  checks: { timeout_s: number; runner?: "container" | "remote"; remote?: RemoteRunnerConfig; [cls: string]: string | number | RemoteRunnerConfig | undefined };
225
257
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
@@ -278,8 +310,11 @@ export const DEFAULT_CONFIG: HubConfig = {
278
310
  ...nativeDefaults.sandbox,
279
311
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
280
312
  approvals: { timeout_s: 120, notify: false },
281
- tasks: { release_after_min: 30, escalate_after: ESCALATE_AFTER, auto_pull: false },
313
+ 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
314
  task_sweep: DEFAULT_TASK_SWEEP,
315
+ schedules: [],
316
+ folder_triggers: [],
317
+ corpus: { paths: [] },
283
318
  checks: { timeout_s: 600 },
284
319
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
285
320
  snapshots: { enabled: false, keep: 20 },
@@ -297,14 +332,14 @@ export const DEFAULT_CONFIG: HubConfig = {
297
332
  export { stateDirFor };
298
333
 
299
334
  /** 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"]);
335
+ 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
336
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
302
337
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
303
338
 
304
339
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
305
340
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
306
341
  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"];
342
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
308
343
 
309
344
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
310
345
  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 +416,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
381
416
  let file: Record<string, any>; // parsed JSON, checked field by field below
382
417
  if (!captured.some(layer => layer.name === name)) return [];
383
418
  try {
384
- file = structuredClone(captured.find(layer => layer.name === name)!.raw);
419
+ file = withoutInertInstancePolicy(structuredClone(captured.find(layer => layer.name === name)!.raw), instanceConfig.declaredExternalInstances);
385
420
  } catch (error) {
386
421
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
387
422
  throw error;
@@ -394,6 +429,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
394
429
  profileName(file.profile);
395
430
  resolveProfile(file.profile, file.profile_overrides);
396
431
  projectionConfig(file.text_projection);
432
+ corpusConfig(file.corpus);
433
+ queueBackpressurePolicy(file.tasks?.queue_backpressure);
434
+ validateOperationalConfig(file);
397
435
  for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
398
436
  const raw = file[block];
399
437
  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 +449,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
411
449
  terminalTemplate(file.terminal?.open);
412
450
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
413
451
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
452
+ delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
414
453
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
415
454
  if (file.checks?.runner === "remote" || file.checks?.remote !== undefined) {
416
455
  if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
@@ -423,6 +462,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
423
462
  });
424
463
  if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
425
464
  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] }])) }));
465
+ const pressureLayers = files.filter(layer => layer.tasks?.queue_backpressure !== undefined);
466
+ if (pressureLayers.length) file.tasks = { ...file.tasks, queue_backpressure: pressureLayers.reduce((prior, layer) => ({ ...prior, ...layer.tasks.queue_backpressure }), {}) };
426
467
  const wipLayers = files.filter(layer => layer.tasks?.wip_limits !== undefined);
427
468
  if (wipLayers.length) file.tasks = { ...file.tasks, wip_limits: wipLayers.reduce((prior, layer) => ({ ...prior, ...planningWipLimits(layer.tasks.wip_limits) }), {} as Record<string, number>) };
428
469
  const overrides = files.reduce<unknown>((prior, layer) => mergeProfileOverrides(prior, layer.profile_overrides), undefined);
@@ -478,7 +519,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
478
519
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
479
520
  ...(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
521
  roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
481
- roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
522
+ roles: Object.fromEntries(Object.entries({ ...configuredRoles, ...conductorPolicy.roles, ...extraRoles }).filter(([id]) => !instanceConfig.declaredExternalInstances?.some(row => row.id === id || row.kind === id))),
482
523
  planner: conductorPolicy.planner,
483
524
  conductor: conductorPolicy.conductor,
484
525
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
@@ -492,6 +533,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
492
533
  },
493
534
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
494
535
  task_sweep: taskSweepConfig(file.task_sweep),
536
+ schedules: schedulesConfig(file.schedules),
537
+ folder_triggers: foldersConfig(file.folder_triggers),
538
+ corpus: corpusConfig(file.corpus),
495
539
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
496
540
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
497
541
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
@@ -534,6 +578,8 @@ export interface DaemonOptions {
534
578
  memoryClient?: MemoryClient;
535
579
  /** Host-only remote-default evidence seam; never read from config or control messages. */
536
580
  shellDefaultBranchProbe?: typeof readTaskShellDefaultBranch;
581
+ /** Host-only workspace-validation Git seam; never supplied by project config or control messages. */
582
+ instanceExecutionGit?: typeof hubGitSync;
537
583
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
538
584
  snapshot?: typeof snapshot;
539
585
  snapshotBurstMs?: number;
@@ -580,8 +626,28 @@ export interface DaemonOptions {
580
626
  agentDriftEveryMs?: number;
581
627
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
582
628
  bandsHeld?: () => boolean;
629
+ /** Test seam: the schedules clock and the period of the due check (default Date.now and one minute). */
630
+ schedulesNow?: () => number;
631
+ schedulesEveryMs?: number;
632
+ /** Test seam: an extra hold on the schedules timer, like a storage error. */
633
+ schedulesHeld?: () => boolean;
634
+ /** Test seam: replaces the PII screen's model call, so a test can hold a screen open. */
635
+ piiScreen?: (text: string) => Promise<PiiVerdict>;
636
+ /** Test seam: how often the scheduled security-scan baseline checks whether a scan is due, default one hour (the configured `every_h` still decides). */
637
+ securityScanEveryMs?: number;
583
638
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
584
639
  researchGraceMs?: number;
640
+ /**
641
+ * Host-only test seam (#546), never read from config or a control message: `false` leaves the config file watcher off, so an
642
+ * explicit `settings_reload` is the only publisher. With it on, the watcher and a reload of the same bytes race: whichever runs
643
+ * second reports `applied: []`, and a watcher pass between a preview and its confirm replaces the confirmation token.
644
+ */
645
+ configWatch?: boolean;
646
+ /**
647
+ * 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
648
+ * 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.
649
+ */
650
+ webFetchTransport?: import("../local/web-fetch.ts").WebFetchPort["fetch"];
585
651
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
586
652
  memorySync?: typeof syncMemory;
587
653
  /** Host-only test/embedding transports. Never read from config or tool input. */
@@ -597,6 +663,7 @@ interface Client {
597
663
  role?: "peer" | "console" | "tools";
598
664
  peer?: PeerId;
599
665
  launchId?: string;
666
+ externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
600
667
  toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
601
668
  tail?: () => void;
602
669
  watch?: () => void;
@@ -628,22 +695,59 @@ export function resolutionRefusal(error: unknown): string {
628
695
  /** 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
696
  export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
630
697
 
698
+ let recoverySourcePackageDigest: string | null | undefined;
699
+ function capturedRecoverySourceDigest(): string | undefined {
700
+ if (recoverySourcePackageDigest === undefined) {
701
+ try { recoverySourcePackageDigest = packageDigest(resolve(import.meta.dir, "../..")); }
702
+ catch { recoverySourcePackageDigest = null; }
703
+ }
704
+ return recoverySourcePackageDigest ?? undefined;
705
+ }
706
+
631
707
  export async function startDaemon(opts: DaemonOptions) {
708
+ const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
709
+ resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
632
710
  const projectId = opts.projectId ?? randomUUID();
711
+ const sourcePackageDigest = capturedRecoverySourceDigest();
633
712
  const instanceId = opts.instanceId ?? randomUUID();
634
713
  const startupCleanup: (() => void | Promise<void>)[] = [];
635
714
  let ready = false;
636
715
  try {
637
716
  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.
717
+ let recoveryConfiguration: HubConfig | undefined, recoveryRouting: Routing | undefined, recoverySources: ConfigSourceTexts | undefined, recoveryDefaultRouting: string | undefined;
718
+ const recoveryInitiator = process.env.AGENTHUB_RECOVERY_CONDUCTOR;
719
+ if (recoveryInitiator !== undefined) {
720
+ // Before module imports or factories: the exact startup snapshot must match the source-issued policy.
721
+ try {
722
+ const operationId = process.env.AGENTHUB_RECOVERY_OPERATION;
723
+ if (!operationId) throw new RecoveryAuthorityError();
724
+ const operation = readOperation<import("../cli/upgrade.ts").RecoveryOperation>(operationId);
725
+ if (!operation || operation.id !== operationId) throw new RecoveryAuthorityError();
726
+ const snapshot = readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId });
727
+ if (!snapshot) throw new RecoveryAuthorityError();
728
+ const inputs = readRecoveryPolicyInputs(opts.cwd, operation.conductorRoutingDefaults?.[projectId]);
729
+ const candidate = loadConfig(opts.cwd, inputs.layers, inputs.routing.retired ?? [], []);
730
+ const expected = conductorRecoveryBinding(operation, { root: opts.cwd, id: projectId, stateDir: opts.stateDir }, recoveryInitiator, { config: candidate, inputs });
731
+ if (expected.sourceInstanceId !== snapshot.sourceInstanceId || expected.targetRoot !== resolve(import.meta.dir, "../..") || expected.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
732
+ 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; });
733
+ assertConductorRecovery(proof);
734
+ startupLayers = inputs.layers; recoveryConfiguration = candidate; recoveryRouting = inputs.routing; recoverySources = inputs.texts; recoveryDefaultRouting = inputs.routingFallback;
735
+ } catch { throw new RecoveryAuthorityError(); }
736
+ delete process.env.AGENTHUB_RECOVERY_CONDUCTOR;
737
+ delete process.env.AGENTHUB_RECOVERY_CONTINUATION;
738
+ if (process.env.AGENTHUB_PEER_ID === recoveryInitiator) delete process.env.AGENTHUB_PEER_ID;
739
+ } else {
740
+ try { startupLayers = readInstanceConfigLayers(opts.cwd); }
741
+ catch (error) { if (!opts.config) throw error; startupLayers = []; }
742
+ } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
640
743
  const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
641
744
  const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
642
745
  const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
643
746
  const externalIds = new Set(externalAdapters.map(item => item.kind));
644
747
  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);
748
+ const startupDeclarationRows = readInstanceConfig(opts.cwd, startupLayers, externalDefinitions).declaredExternalInstances ?? [];
749
+ const loadedConfig = recoveryConfiguration ?? (opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
750
+ : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions));
647
751
  reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
648
752
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
649
753
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
@@ -663,6 +767,9 @@ export async function startDaemon(opts: DaemonOptions) {
663
767
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
664
768
  const implementerTranscripts = new Map<string, string>();
665
769
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
770
+ const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
771
+ const externalBindings = new Map<string, ExternalInstanceBinding>();
772
+ const externalSessions = new Map<string, string>();
666
773
  const verifiedTerminalRootsThisTurn = new Set<object>();
667
774
  const reviewerUnavailable = new Map<string, string>();
668
775
  const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
@@ -686,6 +793,66 @@ export async function startDaemon(opts: DaemonOptions) {
686
793
  roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
687
794
  // The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
688
795
  const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
796
+ const externalInstanceIds = extraIds.filter(id => externalIds.has(declarations[id]!.kind));
797
+ // Instance declarations and their effective role ceiling are restart-bound, even for a fresh native session.
798
+ const externalStartupPolicies = new Map(externalInstanceIds.map(id => [id, { declaration: structuredClone(declarations[id]!), roles: [...(config.roles[id] ?? [])] }]));
799
+ const externalRefusedRows = new Map(startupDeclarationRows.map(row => [row.id, row]));
800
+ const allExternalInstanceIds = () => [...new Set([...(restored?.peers ?? []).filter(row => row.externalInstance).map(row => row.id), ...externalInstanceIds, ...externalRefusedRows.keys()])];
801
+ const isExternalInstance = (peer: string) => externalInstanceIds.includes(peer);
802
+ // ponytail: each instance costs about 10 synchronous Git calls per fresh turn (64 instances maximum);
803
+ // a stat-keyed evidence cache is the upgrade path, preserving fresh effect-boundary rechecks.
804
+ const externalEvidenceThisTurn = new Map<string, { binding: ExternalInstanceBinding; workspace: string } | undefined>();
805
+ let externalEvidenceExpiryQueued = false;
806
+ function externalEvidence(peer: string, fresh = false): { binding: ExternalInstanceBinding; workspace: string } | undefined {
807
+ if (!fresh && externalEvidenceThisTurn.has(peer)) return externalEvidenceThisTurn.get(peer);
808
+ let evidence: { binding: ExternalInstanceBinding; workspace: string } | undefined;
809
+ try {
810
+ if (externalRefusedRows.has(peer)) throw new Error("external instance module refused");
811
+ const binding = captureExternalBinding(peer);
812
+ const retained = externalBindings.get(peer);
813
+ if (retained && !sameExternalInstanceBinding(retained, binding)) throw new Error("external instance binding changed");
814
+ evidence = { binding, workspace: externalWorkspaceIdentity(binding.executionCwd) };
815
+ externalBindings.set(peer, binding);
816
+ } catch { /* Unavailable evidence is cached only for this synchronous turn, never authority. */ }
817
+ externalEvidenceThisTurn.set(peer, evidence);
818
+ if (!externalEvidenceExpiryQueued) {
819
+ externalEvidenceExpiryQueued = true;
820
+ queueMicrotask(() => { externalEvidenceThisTurn.clear(); externalEvidenceExpiryQueued = false; });
821
+ }
822
+ return evidence;
823
+ }
824
+ function externalWorkspaceIdentity(cwd: string): string {
825
+ const info = lstatSync(cwd);
826
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("external workspace directory changed");
827
+ const branch = hubGitSync(["-C", cwd, "symbolic-ref", "-q", "HEAD"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 });
828
+ if (branch.error || branch.status !== 0 && branch.status !== 1) throw new Error("external workspace branch evidence unavailable");
829
+ return `${info.dev}:${info.ino}:${branch.status === 0 ? branch.stdout.trim() : "detached"}`;
830
+ }
831
+ function captureExternalBinding(peer: string, resumeSessionId?: string): ExternalInstanceBinding {
832
+ const definition = definitionOf(peer), identity = definition && externalModuleIdentity(definition);
833
+ if (!identity || !externalModuleIdentityCurrent(definition!)) throw new Error("external instance module binding changed");
834
+ const current = readInstanceConfig(opts.cwd, undefined, externalDefinitions);
835
+ const declaration = current.instances?.[peer];
836
+ if (!declaration || declaration.kind !== declarations[peer]?.kind) throw new Error("external instance declaration changed");
837
+ const policy = loadConductorPolicy(opts.cwd, undefined, current);
838
+ const currentRoles = effectiveInstanceRoles(peer, current.instances, policy.roles, current.catalog);
839
+ const cwd = resolveExecutionCwd(peer, current.instances, currentRoles, current.catalog);
840
+ const binding = externalInstanceBinding(identity, peer, declaration, currentRoles, cwd);
841
+ const startup = externalStartupPolicies.get(peer);
842
+ 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");
843
+ const record = readExternalInstanceRecord(opts.stateDir, peer);
844
+ const sameBinding = record && sameExternalInstanceBinding(record.externalInstance, binding);
845
+ if (resumeSessionId !== undefined) {
846
+ if (record && !sameBinding) throw new Error("external instance retained record binding changed");
847
+ if (!record || record.sessionId !== resumeSessionId) throw new Error("external instance resume requires its recorded native session id");
848
+ }
849
+ if (sameBinding) externalSessions.set(peer, record.sessionId);
850
+ else externalSessions.delete(peer); // Changed continuity does not prevent a person's fresh session. Successful start replaces the record.
851
+ return binding;
852
+ }
853
+ function retainedExternalBinding(peer: string, fresh = false): ExternalInstanceBinding | undefined {
854
+ return externalEvidence(peer, fresh)?.binding;
855
+ }
689
856
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
690
857
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
691
858
  const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
@@ -694,8 +861,29 @@ export async function startDaemon(opts: DaemonOptions) {
694
861
  const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
695
862
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
696
863
  const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
864
+ /** Display the same runtime form/role ceilings used by declarations; these fields grant no authority. */
865
+ function roleVisibility(peer: string): { form?: string; capableRoles: string[] } {
866
+ const definition = definitionOf(peer), declaration = declarations[peer];
867
+ if (!definition) return { capableRoles: [] };
868
+ const fixed = isFixedBuiltinDefinition(definition);
869
+ const candidates: readonly string[] = fixed ? definition.routing?.reviewOnly ? definition.roles : definition.instances?.roles ?? ["planner", "implementer", "verifier", "reviewer", "conductor"]
870
+ : declaration ? definition.headlessInstances?.roles ?? definition.roles : definition.roles;
871
+ const capableRoles = candidates.filter(role => (!definition.routing?.allowedRoles || definition.routing.allowedRoles.includes(role))
872
+ && (role !== "planner" || (fixed ? definition.projection?.plannerEligible === true : !declaration && definition.roles.includes(role)))
873
+ && (fixed || role !== "conductor"));
874
+ const owner = bus.peers.get(peer);
875
+ const form = !declaration ? owner && "form" in owner ? owner.form : startMode(peer) : definition.instances?.mode === "external"
876
+ ? (config.roles[peer] ?? []).some(role => role === "implementer" || role === "verifier") ? "terminal-implementer" : "channel" : "headless";
877
+ return { form, capableRoles: [...capableRoles] };
878
+ }
879
+
697
880
  /** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
698
881
  const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
882
+ const holderReasons = (view: Record<string, unknown>) => ({
883
+ ...(typeof view.plannerReason === "string" ? { plannerReason: view.plannerReason } : {}),
884
+ ...(typeof view.ownerReason === "string" ? { ownerReason: view.ownerReason } : {}),
885
+ ...(typeof view.reviewerReason === "string" ? { reviewerReason: view.reviewerReason } : {}),
886
+ });
699
887
  /** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
700
888
  const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => {
701
889
  if (!reviewOnlyPeer(peer) || !Array.isArray(view.history)) return view;
@@ -733,7 +921,8 @@ export async function startDaemon(opts: DaemonOptions) {
733
921
  const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
734
922
  const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
735
923
  // 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);
924
+ const remoteRoster = new Set<string>();
925
+ const membershipAllowed = (peer: string) => peer.includes("@") ? remoteRoster.has(peer) : definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
737
926
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
738
927
  const mlxConfigProblem = mlxLaunchProblem(config, config.pi) ?? disabledMlxPolicyProblem(config, opts.cwd);
739
928
  if (mlxConfigProblem) throw new Error(mlxConfigProblem);
@@ -747,8 +936,10 @@ export async function startDaemon(opts: DaemonOptions) {
747
936
  let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
748
937
  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
938
  };
750
- let runningRouting = currentRouting(opts.cwd, log);
751
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
939
+ let runningRoutingDefault = recoveryDefaultRouting;
940
+ if (runningRoutingDefault === undefined) try { runningRoutingDefault = recoveryRoutingFallback(); } catch { /* unknown defaults refuse conductor recovery */ }
941
+ let runningRouting = recoveryRouting ?? currentRouting(opts.cwd, log);
942
+ let acceptedSources = parseConfigSources(recoverySources ?? configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters, externalDefinitions);
752
943
  const startupSources = structuredClone(acceptedSources.trustLayers);
753
944
  const embeddedConfig = structuredClone(config);
754
945
  let desiredConfig = structuredClone(config);
@@ -945,6 +1136,16 @@ export async function startDaemon(opts: DaemonOptions) {
945
1136
  ? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
946
1137
  : undefined;
947
1138
  if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
1139
+ for (const peer of restored?.peers ?? []) if (peer.externalInstance) {
1140
+ try {
1141
+ if (!isExternalInstance(peer.id) || !sameExternalInstanceBinding(peer.externalInstance, captureExternalBinding(peer.id))) throw new Error("external instance retained binding is unavailable");
1142
+ } catch {
1143
+ // Preserve historical correlation/integrity while refusing this instance alone. Builtins still restore.
1144
+ externalRefusedRows.set(peer.id, { id: peer.id, kind: peer.externalInstance.kind, availability: "refused", refusalCode: "module-refused" });
1145
+ }
1146
+ externalBindings.set(peer.id, peer.externalInstance);
1147
+ if (peer.sessionId) externalSessions.set(peer.id, peer.sessionId);
1148
+ }
948
1149
 
949
1150
  // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
950
1151
  const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
@@ -1030,6 +1231,15 @@ export async function startDaemon(opts: DaemonOptions) {
1030
1231
  log(`network: egress proxy on 127.0.0.1:${egress.port}, ${allow.length} allowed host(s) (local.network_allow)`);
1031
1232
  }
1032
1233
  const sandboxNetwork: SandboxNetwork = config.local.bash_network === "direct" ? true : egress ? { proxyPort: egress.port } : false;
1234
+ // #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
1235
+ // 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.
1236
+ const webAllow = () => webFetchAllow(config.local.web_fetch?.allow);
1237
+ let webEgress: EgressProxy | undefined;
1238
+ if (webAllow().length) {
1239
+ webEgress = await startEgressProxy({ allow: webAllow(), log, allowFn: webAllow });
1240
+ startupCleanup.push(() => void webEgress?.close());
1241
+ log(`network: web_fetch egress proxy on 127.0.0.1:${webEgress.port}, ${webAllow().length} allowed host(s) (local.web_fetch.allow)`);
1242
+ }
1033
1243
  const admit = (env: Envelope, parent?: string): string | undefined => {
1034
1244
  // [FYI] is recorded and costs nobody a turn: nothing to limit.
1035
1245
  if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
@@ -1079,7 +1289,20 @@ export async function startDaemon(opts: DaemonOptions) {
1079
1289
  const staleOff = () => config.experiments?.stale_notices === "deliver";
1080
1290
  if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
1081
1291
  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,
1292
+ const queuePolicy = () => queueBackpressurePolicy(config.tasks.queue_backpressure);
1293
+ let remotePending = (_room: string): readonly string[] => [];
1294
+ let remoteQueueChanged = () => {};
1295
+ const bus: Bus = new Bus({ rostered: membershipAllowed, 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,
1296
+ remotePending: room => remotePending(room),
1297
+ remotePrivate: (env): boolean => {
1298
+ const task = env.refs?.task;
1299
+ if (task !== undefined) {
1300
+ if (typeof task !== "string" || !/^[1-9][0-9]*$/.test(task) || !Number.isSafeInteger(Number(task))) return true;
1301
+ const row = board.get(Number(task)); if (!row || tasks.isPii(row)) return true;
1302
+ }
1303
+ return env.from === USER ? false : !bus.peers.has(env.from) || !!turns.get(env.from)?.private || contextPii(env.from);
1304
+ },
1305
+ doing: (peer, kind, text) => observeDoing(peer, kind, text),
1083
1306
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1084
1307
  startupCleanup.push(() => bus.closeJournal());
1085
1308
  const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
@@ -1504,7 +1727,7 @@ export async function startDaemon(opts: DaemonOptions) {
1504
1727
  const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
1505
1728
  const connectionGeneration = (id: string): string | undefined => {
1506
1729
  const peer = bus.peers.get(id);
1507
- if (!peer || peer.state === "offline") return undefined;
1730
+ if (!peer || peer.state === "offline" || isExternalInstance(id) && !currentExecutionOwner(id)) return undefined;
1508
1731
  let token = checkConnections.get(peer);
1509
1732
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1510
1733
  const hasGeneration = "sessionGeneration" in peer;
@@ -1520,6 +1743,25 @@ export async function startDaemon(opts: DaemonOptions) {
1520
1743
  };
1521
1744
  const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
1522
1745
  && !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
1746
+ let corpusRuntime: { key: string; index: CorpusIndex } | undefined;
1747
+ const corpusPolicyVersion = () => JSON.stringify({ revision: configRevision, paths: config.corpus.paths, pii: config.pii?.paths ?? [], deny: config.local.deny, signals: activeRouting().signals });
1748
+ const publicCorpus = (query: string, allowed: () => boolean) => {
1749
+ if (!allowed() || !config.corpus.paths.length) return [];
1750
+ const key = corpusPolicyVersion();
1751
+ 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] }) };
1752
+ return corpusEvidence(query, corpusRuntime.index, {
1753
+ allowed, version: corpusPolicyVersion,
1754
+ safe: (text, paths) => boundedSafe(text) && !mentionsPrivatePath(text, paths.map(path => `path:${path}`), [...config.pii?.paths ?? [], ...config.local.deny], opts.cwd),
1755
+ });
1756
+ };
1757
+ const corpusBriefAllowed = (task: BriefTask) => {
1758
+ const latest = task.id === undefined ? undefined : board.get(task.id);
1759
+ return !stopping && !!latest && latest.created === task.created && latest.updated === task.updated && latest.owner === task.owner
1760
+ && (!task.history || latest.history.length === task.history.length) && !tasks.isPii(latest)
1761
+ && !turns.get(latest.owner ?? "")?.private && !contextPii(latest.owner ?? "")
1762
+ && boundedSafe(JSON.stringify({ title: task.title, refs: task.refs, plan: task.plan }));
1763
+ };
1764
+
1523
1765
  const publicCheck = (receipt: RunReceipt) => {
1524
1766
  const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
1525
1767
  return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
@@ -1618,6 +1860,12 @@ export async function startDaemon(opts: DaemonOptions) {
1618
1860
  const tasks: Tasks = new Tasks({
1619
1861
  releaseWindow,
1620
1862
  capabilities: peerCapabilities, repository: memoryRepo,
1863
+ contextAdmission: () => ({
1864
+ policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
1865
+ peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
1866
+ contexts.admission(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer))),
1867
+ ])) : {},
1868
+ }),
1621
1869
  plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1622
1870
  const codes: ("approvals" | "quota")[] = [];
1623
1871
  if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
@@ -1634,6 +1882,8 @@ export async function startDaemon(opts: DaemonOptions) {
1634
1882
  const scope = childToolAuthority.getStore();
1635
1883
  return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
1636
1884
  },
1885
+ // #933: a conductor task op admitted in onMessage runs inside operationalContext; the role is rechecked on every ask.
1886
+ operational: by => { const scope = operationalContext.getStore(); if (!scope || scope.actor !== by) return false; scope.current(); return true; },
1637
1887
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1638
1888
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1639
1889
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
@@ -1645,6 +1895,8 @@ export async function startDaemon(opts: DaemonOptions) {
1645
1895
  recordPlanning: change => event({ type: "planning", ...change }),
1646
1896
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1647
1897
  escalateAfter: () => config.tasks.escalate_after ?? ESCALATE_AFTER,
1898
+ groomFyiWindowMin: () => config.tasks.groom_fyi_window_min ?? GROOM_FYI_WINDOW_SETTING.defaultValue,
1899
+ ...taskOperationalReaders(() => config), // #489 H2
1648
1900
  profile: profileName(config.profile),
1649
1901
  profileData: resolvedProfile,
1650
1902
  piiPaths: config.pii?.paths,
@@ -1695,11 +1947,15 @@ export async function startDaemon(opts: DaemonOptions) {
1695
1947
  ...(config.memory.enabled ? { memory } : {}),
1696
1948
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1697
1949
  memoryCheck: checkMemory,
1698
- ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1950
+ briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1699
1951
  memoryStore ? taskMemoryItems : undefined,
1700
1952
  (peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
1701
1953
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
1702
- memoryStore ? taskIntentBrief : undefined) } : {}),
1954
+ memoryStore ? taskIntentBrief : undefined,
1955
+ {
1956
+ allowed: corpusBriefAllowed, policyVersion: corpusPolicyVersion,
1957
+ 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, " ")}` })),
1958
+ }),
1703
1959
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1704
1960
  checkMemory();
1705
1961
  const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
@@ -1712,7 +1968,8 @@ export async function startDaemon(opts: DaemonOptions) {
1712
1968
  ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
1713
1969
  notify,
1714
1970
  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)) } : {}),
1971
+ ...(checksAllowed ? { check: (cls: string, task?: import("./board.ts").Task) => checkCommands[task?.profileClass ?? cls] ?? checkCommands[cls],
1972
+ 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
1973
  // What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
1717
1974
  // Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
1718
1975
  share: (by, line) => {
@@ -1730,15 +1987,20 @@ export async function startDaemon(opts: DaemonOptions) {
1730
1987
  }
1731
1988
  },
1732
1989
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
1990
+ // #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`.
1991
+ intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
1992
+ partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
1733
1993
  // 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 }),
1994
+ piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
1735
1995
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1736
1996
  quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
1737
1997
  review: config.review,
1738
1998
  sweep: taskSweepConfig(config.task_sweep),
1739
1999
  sweepHeld: () => stopping || recoveryActive(),
2000
+ submodules: () => factsRepo ? submodulePaths(factsRepo) : [], // #798 D1: read from .gitmodules, no git call
1740
2001
  ciWatch: opts.ciWatch,
1741
2002
  autoPull: () => config.tasks.auto_pull === true,
2003
+ queueBackpressure: queuePolicy,
1742
2004
  cancellationHeld: () => stopping || recoveryActive(),
1743
2005
  offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1744
2006
  offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer?.pullOnly === true; },
@@ -2025,6 +2287,42 @@ export async function startDaemon(opts: DaemonOptions) {
2025
2287
  };
2026
2288
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
2027
2289
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
2290
+ // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2291
+ const doings = new Map<PeerId, DoingTracker>();
2292
+ const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
2293
+ if (kind === "reset") { doings.delete(peer); claudeDoing.delete(peer); return; }
2294
+ let tracker = doings.get(peer);
2295
+ if (!tracker) doings.set(peer, tracker = new DoingTracker());
2296
+ // Private when learned or observed in a PII context or refused by the public metadata screen; a throw is private too.
2297
+ tracker.observe(kind, text, value => learnedPrivate || contextPii(peer) || !tasks.publicMetadataText(value));
2298
+ };
2299
+ /** Shown only for an attached peer; a stub while it owns or reviews a PII task or is in a private turn. */
2300
+ const doingRows = (ids: string[]): Record<string, PeerDoing> => {
2301
+ const live = ids.filter(id => doings.has(id) && bus.stateOf(id) !== "offline");
2302
+ if (!live.length) return {};
2303
+ const pii = board.list().filter(t => !isClosedTaskState(t.state) && tasks.isPii(t));
2304
+ return Object.fromEntries(live.flatMap(id => {
2305
+ const view = doings.get(id)!.view(!!turns.get(id)?.private || pii.some(t => t.owner === id || t.reviewer === id));
2306
+ return view ? [[id, view]] : [];
2307
+ }));
2308
+ };
2309
+ // A Claude session's title, read from its transcript where the last read stopped; its current item from PreToolUse input.
2310
+ const claudeDoing = new Map<PeerId, { session: string; cursor: TitleCursor; todos: ClaudeTodoSource }>();
2311
+ const observeClaudeDoing = (peer: PeerId, session: string, transcript: string | undefined, tool: string, input: Record<string, unknown>) => {
2312
+ let source = claudeDoing.get(peer);
2313
+ if (source?.session !== session) {
2314
+ observeDoing(peer, "reset");
2315
+ claudeDoing.set(peer, source = { session, cursor: { path: "", offset: 0, skip: false }, todos: new ClaudeTodoSource() });
2316
+ }
2317
+ if (transcript) {
2318
+ // ponytail: one bounded read per hook call, so a long transcript first seen mid-session catches up over several calls.
2319
+ if (source.cursor.path !== transcript) source.cursor = { path: transcript, offset: 0, skip: false };
2320
+ const title = readAiTitle(source.cursor);
2321
+ if (title !== undefined) observeDoing(peer, "title", title);
2322
+ }
2323
+ const item = tool ? source.todos.item(tool, input, subject => contextPii(peer) || !tasks.publicMetadataText(subject)) : undefined;
2324
+ if (item !== undefined) observeDoing(peer, "todo", item?.text, item?.private === true);
2325
+ };
2028
2326
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
2029
2327
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
2030
2328
  if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
@@ -2166,11 +2464,13 @@ export async function startDaemon(opts: DaemonOptions) {
2166
2464
  };
2167
2465
  const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
2168
2466
  bus.onDeliver = (peer, originals) => {
2467
+ if (peer.includes("@")) return;
2169
2468
  pendingDeliveryTask.delete(peer);
2170
2469
  const task = deliveryTask(originals);
2171
2470
  if (task !== undefined) pendingDeliveryTask.set(peer, task);
2172
2471
  };
2173
2472
  bus.onDelivered = (peer, originals) => {
2473
+ if (peer.includes("@")) return;
2174
2474
  pendingDeliveryTask.delete(peer);
2175
2475
  if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
2176
2476
  const turn = turns.get(peer);
@@ -2201,8 +2501,9 @@ export async function startDaemon(opts: DaemonOptions) {
2201
2501
  if (row && tasks.isPii(row)) return;
2202
2502
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
2203
2503
  }) : undefined;
2204
- bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
2504
+ 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
2505
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
2506
+ if (peer.includes("@") || envs.some(env => env.from.includes("@") || env.to?.some(id => id.includes("@")))) return;
2206
2507
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
2207
2508
  memoryObservations?.cite(peer, text, id, envs);
2208
2509
  };
@@ -2253,9 +2554,15 @@ export async function startDaemon(opts: DaemonOptions) {
2253
2554
  executionOwners.delete(peer); executionRoots.delete(peer);
2254
2555
  worktreeFacts.delete(peer); worktreeProjections.delete(peer);
2255
2556
  }
2256
- function currentExecutionOwner(peer: string) {
2557
+ function currentExecutionOwner(peer: string, fresh = false) {
2257
2558
  const bound = executionOwners.get(peer);
2258
- if (bound?.socket) {
2559
+ if (bound && isExternalInstance(peer)) {
2560
+ const launch = externalLaunches.get(peer), receipt = launch && managedAcpLaunch(bound.owner, launch.options);
2561
+ const evidence = externalEvidence(peer, fresh), current = evidence?.binding, workspace = evidence?.workspace;
2562
+ 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)) {
2563
+ revokeTerminalExecution(peer); return undefined;
2564
+ }
2565
+ } else if (bound?.socket) {
2259
2566
  const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2260
2567
  if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2261
2568
  revokeTerminalExecution(peer);
@@ -2265,7 +2572,7 @@ export async function startDaemon(opts: DaemonOptions) {
2265
2572
  // an admission/effect. Reuse only within synchronous code, never across an await.
2266
2573
  if (!verifiedTerminalRootsThisTurn.has(bound)) {
2267
2574
  try {
2268
- if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2575
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2269
2576
  } catch { revokeTerminalExecution(peer); return undefined; }
2270
2577
  verifiedTerminalRootsThisTurn.add(bound);
2271
2578
  queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
@@ -2276,13 +2583,23 @@ export async function startDaemon(opts: DaemonOptions) {
2276
2583
  try {
2277
2584
  if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
2278
2585
  if (!verifiedTerminalRootsThisTurn.has(bound)) {
2279
- if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2586
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2280
2587
  verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2281
2588
  }
2282
2589
  } catch { revokeTerminalExecution(peer); return undefined; }
2283
2590
  }
2284
2591
  return bound;
2285
2592
  }
2593
+ /** Last verified roots for display only; no filesystem/Git probe and never owner-admission authority. */
2594
+ function ownerIsolationProjection(): Readonly<Record<string, boolean>> {
2595
+ return Object.fromEntries(extraIds.map(peer => {
2596
+ const bound = executionOwners.get(peer), owner = bus.peers.get(peer);
2597
+ if (!bound || !owner || bound.owner !== owner || owner.state === "offline" || bound.cwd === opts.cwd || bound.cwd !== executionRoots.get(peer)) return [peer, false];
2598
+ if (bound.socket && (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner)
2599
+ || bound.owner.sessionGeneration !== bound.generation || !bound.owner.owns(bound.socket))) return [peer, false];
2600
+ return [peer, true];
2601
+ }));
2602
+ }
2286
2603
  function ownerWriteRoot(peer: string): string | undefined {
2287
2604
  if (declarations[peer]) {
2288
2605
  const bound = currentExecutionOwner(peer);
@@ -2290,10 +2607,19 @@ export async function startDaemon(opts: DaemonOptions) {
2290
2607
  }
2291
2608
  return executionRoot(peer);
2292
2609
  }
2610
+ function currentExternalTools(client: Client, fresh = false): boolean {
2611
+ const peer = client.peer, bound = client.externalOwner;
2612
+ if (!peer || !bound || stopping || recoveryActive() || !membershipAllowed(peer)) return false;
2613
+ const launch = externalLaunches.get(peer), current = currentExecutionOwner(peer, fresh);
2614
+ if (launch && current && launch.owner === bound.owner && current.owner === bound.owner && launch.launchId === bound.launchId && launch.generation && bound.generation === undefined) bound.generation = launch.generation;
2615
+ return !!launch && !!current && launch.owner === bound.owner && current.owner === bound.owner && launch.launchId === bound.launchId && launch.generation === bound.generation;
2616
+ }
2293
2617
  function implementerChatTask(peer: string): number | undefined {
2294
2618
  const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
2295
2619
  return owned.length === 1 ? owned[0]!.id : undefined;
2296
2620
  }
2621
+ type ImplementerFailureEntry = { launchId: string; generation: string; signal: AbortSignal; trace: ClaudeFailureTrace; probeCount: number; permissionAttempt: number };
2622
+ const implementerFailures = new WeakMap<PeerAdapter, ImplementerFailureEntry>();
2297
2623
  function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
2298
2624
  const bound = c.toolsOwner, peer = c.peer;
2299
2625
  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 +2637,10 @@ export async function startDaemon(opts: DaemonOptions) {
2311
2637
  const binding = c.toolsOwner, launch = currentImplementerTools(c);
2312
2638
  return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2313
2639
  }
2640
+ function currentImplementerFailure(c: Client, launch = currentImplementerTools(c)): ImplementerFailureEntry | undefined {
2641
+ const owner = c.toolsOwner?.owner, entry = owner && implementerFailures.get(owner);
2642
+ return launch && entry && entry.launchId === launch.launchId && entry.generation === launch.generation && entry.signal === launch.signal && !entry.signal.aborted ? entry : undefined;
2643
+ }
2314
2644
  const shellProjectRepository = githubOriginRepository(opts.cwd);
2315
2645
  const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
2316
2646
  const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
@@ -2318,7 +2648,7 @@ export async function startDaemon(opts: DaemonOptions) {
2318
2648
  : undefined;
2319
2649
  let implementerShellStart: Promise<HostShellWrapper> | undefined;
2320
2650
  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> {
2651
+ 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
2652
  const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
2323
2653
  if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
2324
2654
  if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
@@ -2387,7 +2717,8 @@ export async function startDaemon(opts: DaemonOptions) {
2387
2717
  const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
2388
2718
  const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
2389
2719
  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 } : {}) });
2720
+ signal: launch.signal, timeoutMs: Number(input.timeout ?? 120_000), current: recheck, activity: () => c.toolsOwner?.owner.keepApprovalAlive?.(),
2721
+ onRefused: () => { if (failure && failure.entry.permissionAttempt === failure.attempt) failure.entry.trace.record("wrapper_refused"); }, ...(sandbox ? { sandbox } : {}) });
2391
2722
  if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
2392
2723
  return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
2393
2724
  }
@@ -2573,6 +2904,11 @@ export async function startDaemon(opts: DaemonOptions) {
2573
2904
  bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
2574
2905
  } catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2575
2906
  }, opts.bandsEveryMs ?? 300_000),
2907
+ // #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.
2908
+ setInterval(() => {
2909
+ if (stopping || recoveryActive() || opts.bandsHeld?.()) return;
2910
+ void tasks.runSecurityScan().catch(error => log(`sdlc security scan failed: ${sanitize((error as Error).message).slice(0, 160)}`));
2911
+ }, opts.securityScanEveryMs ?? 3_600_000),
2576
2912
  setInterval(() => {
2577
2913
  if (!ready || !memoryBinding) return;
2578
2914
  const before = memoryProblem;
@@ -2613,6 +2949,54 @@ export async function startDaemon(opts: DaemonOptions) {
2613
2949
  };
2614
2950
  rescheduleSweep();
2615
2951
  startupCleanup.push(() => clearInterval(sweepTimer));
2952
+ // #512a: the schedules timer is its own interval, started whenever `schedules` is configured, whatever `task_sweep.enabled` says, and re-armed on reload.
2953
+ // It runs no task sweep. Held while stopping or recovering (inside `proposeFromSchedules`), where a due schedule counts the skip and coalesces.
2954
+ const schedulesStore = new ScheduleStore(join(opts.stateDir, "hub.db"));
2955
+ startupCleanup.push(() => schedulesStore.close());
2956
+ const folderStore = new FolderStore(join(opts.stateDir, "hub.db"));
2957
+ startupCleanup.push(() => folderStore.close());
2958
+ let schedulesTimer: ReturnType<typeof setInterval> | undefined;
2959
+ const scheduleNow = () => opts.schedulesNow?.() ?? Date.now();
2960
+ /** The pass in flight, so a stop can wait for it before the board and the store close. */
2961
+ let schedulesPass: Promise<void> | undefined;
2962
+ /** #335: `ahub schedule run` calls in flight. They are not the timer's pass, but `stopOnce` waits for them just the same. */
2963
+ const manualSchedulePasses = new Set<Promise<void>>();
2964
+ const runSchedules = (): Promise<void> => {
2965
+ if (stopping || schedulesPass) return Promise.resolve();
2966
+ schedulesPass = runSchedulesOnce().finally(() => { schedulesPass = undefined; });
2967
+ return schedulesPass;
2968
+ };
2969
+ const runSchedulesOnce = async () => {
2970
+ try {
2971
+ for (const run of await tasks.proposeFromSchedules(schedulesStore, config.schedules, scheduleNow(), () => opts.schedulesHeld?.() === true)) {
2972
+ if (run.outcome === "ran") log(`schedule ${run.id}: proposed task #${run.task}`);
2973
+ else if (run.outcome === "failed") log(`schedule ${run.id}: could not propose; see ahub status`);
2974
+ }
2975
+ if (config.folder_triggers.length) {
2976
+ const policy: FolderPolicy = { cwd: opts.cwd, dataPaths: config.data?.readonly ?? [], piiPaths: config.pii?.paths ?? [], deny: config.local.deny };
2977
+ // 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
2978
+ // 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.
2979
+ const register = (file: ScannedFile) => registerScanned(file, { registry: sourceRegistry, cwd: opts.cwd, piiPaths: config.pii?.paths ?? [], piiPatterns: () => activeRouting().signals.pii_patterns,
2980
+ project: (extension, data) => projections.projectBytes(join(opts.cwd, `source${extension}`), data)?.text });
2981
+ 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)) {
2982
+ if (run.outcome === "ran") log(`folder trigger ${run.id}: proposed ${run.tasks.map(id => `task #${id}`).join(", ")}`);
2983
+ else if (run.outcome === "failed" || run.outcome === "refused") log(`folder trigger ${run.id}: ${run.outcome}: ${sanitize(run.reason ?? "").slice(0, 160)}`);
2984
+ }
2985
+ }
2986
+ if (!stopping) writeStatus(); // a pass that ends during shutdown must not republish the status file shutdown is about to remove
2987
+ } catch (error) { log(`schedules pass failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2988
+ };
2989
+ const rescheduleSchedules = () => {
2990
+ if (schedulesTimer) clearInterval(schedulesTimer);
2991
+ schedulesTimer = undefined;
2992
+ if (!config.folder_triggers.length) { try { folderStore.prune([]); } catch { /* the store is closed while stopping */ } }
2993
+ if (!config.schedules.length && !config.folder_triggers.length) { try { schedulesStore.prune([]); } catch { /* the store is closed while stopping */ } return; }
2994
+ if (!config.schedules.length) { try { schedulesStore.prune([]); } catch { /* the store is closed while stopping */ } }
2995
+ schedulesTimer = setInterval(() => { void runSchedules(); }, opts.schedulesEveryMs ?? 60_000);
2996
+ schedulesTimer.unref?.();
2997
+ };
2998
+ rescheduleSchedules();
2999
+ startupCleanup.push(() => clearInterval(schedulesTimer));
2616
3000
  for (const i of intervals) i.unref?.();
2617
3001
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
2618
3002
 
@@ -2634,6 +3018,7 @@ export async function startDaemon(opts: DaemonOptions) {
2634
3018
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
2635
3019
  const [by, op, input] = args;
2636
3020
  if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
3021
+ const sourceCurrent = op === "hub_source_read" ? sourceReadCurrent(by, args[3] ?? false, args[4] ?? false) : undefined;
2637
3022
  const nativeTurn = turns.get(by);
2638
3023
  const effectful = ownerToolEffect(op, input);
2639
3024
  if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
@@ -2647,6 +3032,7 @@ export async function startDaemon(opts: DaemonOptions) {
2647
3032
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2648
3033
  const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
2649
3034
  const text = await taskOpBody(...args);
3035
+ if (sourceCurrent && !sourceCurrent()) throw new PreEffectToolRefusal("source read unavailable");
2650
3036
  if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2651
3037
  const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2652
3038
  if (conducted) {
@@ -2654,13 +3040,13 @@ export async function startDaemon(opts: DaemonOptions) {
2654
3040
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
2655
3041
  auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
2656
3042
  }
2657
- const hint = args[3] ? ownHeldHint(by) : "";
3043
+ const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
2658
3044
  return hint ? `${text}\n\n${hint}` : text;
2659
3045
  } catch (error) {
2660
3046
  if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
2661
3047
  if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2662
3048
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2663
- const hint = args[3] ? ownHeldHint(by) : "";
3049
+ const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
2664
3050
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
2665
3051
  throw error;
2666
3052
  } finally {
@@ -2680,6 +3066,16 @@ export async function startDaemon(opts: DaemonOptions) {
2680
3066
  if (!id || !PEER_ID.test(id)) throw new PreEffectToolRefusal(`${name} must be a peer id, not ${JSON.stringify(v).slice(0, 60)}`);
2681
3067
  return id;
2682
3068
  };
3069
+ function sourceReadCurrent(by: PeerId, inProcess: boolean, piiTurn: boolean): () => boolean {
3070
+ const owner = bus.peers.get(by), generation = connectionGeneration(by);
3071
+ const authority = childToolAuthority.getStore();
3072
+ const routing = activeRouting(), policyKey = JSON.stringify({ signals: routing.signals, constraints: routing.constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: routing.local.fixed_model });
3073
+ return () => !stopping && bus.peers.get(by) === owner && connectionGeneration(by) === generation
3074
+ && (!authority || authority.by === by && authority.current())
3075
+ && (!inProcess || !piiTurn || holdsPii(by))
3076
+ && policyKey === JSON.stringify({ signals: activeRouting().signals, constraints: activeRouting().constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: activeRouting().local.fixed_model });
3077
+ }
3078
+ const sourceRegistry = new SourceRegistry(opts.stateDir);
2683
3079
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
2684
3080
  if (BOUNDED_TASK_TOOLS.has(op)) {
2685
3081
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
@@ -2720,6 +3116,29 @@ export async function startDaemon(opts: DaemonOptions) {
2720
3116
  need("propose", "propose tasks");
2721
3117
  if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
2722
3118
  }
3119
+ if (op === "hub_source_read") {
3120
+ validateSourceRead(a);
3121
+ const valid = sourceReadCurrent(by, inProcess, piiTurn);
3122
+ return JSON.stringify(await sourceRead(sourceRegistry, a, {
3123
+ valid,
3124
+ canReadPrivate: async () => {
3125
+ const eligible = () => valid() && inProcess && piiTurn && holdsPii(by)
3126
+ && isFixedBuiltinDefinition(definitionOf(by)) && definitionOf(by)?.trust.onPrem === true;
3127
+ if (!eligible()) return false;
3128
+ const campus = await onCampus().catch(() => false);
3129
+ return campus && eligible();
3130
+ },
3131
+ screen: async (text, meta) => {
3132
+ if (meta.origins.some(origin => origin.kind === "file" && matchesPolicy(opts.cwd, origin.origin, config.pii?.paths ?? []))) return false;
3133
+ try { if (looksSecret(text) || activeRouting().signals.pii_patterns.some(pattern => new RegExp(pattern).test(text))) return false; }
3134
+ catch { return false; }
3135
+ if (!tasks.screenOn()) return true;
3136
+ // Reuse the bounded host screen. Oversized/unknown is private, never an unscreened excerpt.
3137
+ 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);
3138
+ return verdict?.label === "clear";
3139
+ },
3140
+ }));
3141
+ }
2723
3142
  if (MEMORY_READ_TOOLS.has(op)) {
2724
3143
  validateMemoryRead(op, a);
2725
3144
  need("memory_read", "read shared memory");
@@ -2858,10 +3277,11 @@ export async function startDaemon(opts: DaemonOptions) {
2858
3277
  case "hub_task_propose": {
2859
3278
  const t = await tasks.propose(by, a);
2860
3279
  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");
3280
+ const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
3281
+ if (t.state === "backlog") return [line(t), repoHint, submodules].filter(Boolean).join("\n");
2862
3282
  const overlap = tasks.overlaps(t, t.owner === by);
2863
3283
  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");
3284
+ 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
3285
  }
2866
3286
  case "hub_task_accept": {
2867
3287
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2870,7 +3290,7 @@ export async function startDaemon(opts: DaemonOptions) {
2870
3290
  const silent = tasks.silentFor(t.id);
2871
3291
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
2872
3292
  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");
3293
+ return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
2874
3294
  }
2875
3295
  case "hub_task_cancel": {
2876
3296
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -2933,7 +3353,7 @@ export async function startDaemon(opts: DaemonOptions) {
2933
3353
  if (!release) throw new PreEffectToolRefusal("no such release");
2934
3354
  releaseId = release.id;
2935
3355
  }
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) } : {}) }; }));
3356
+ 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
3357
  }
2938
3358
  }
2939
3359
  if (op === "task_show" && by !== USER) {
@@ -2947,12 +3367,31 @@ export async function startDaemon(opts: DaemonOptions) {
2947
3367
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2948
3368
  return tasks.publicExplain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
2949
3369
  }
2950
- if (by !== USER) throw new Error(`${op} is a console command`);
3370
+ if (op === "deliverables" && by !== USER) {
3371
+ // #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.
3372
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
3373
+ const visible = (task: Task) => taskShowAllowed(by, task, config.roles, tasks.isConfiguredPlanner(by));
3374
+ return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true, visible }));
3375
+ }
3376
+ if (op === "task_label_check" && by !== USER) {
3377
+ // #933: an agent labels only a task it owns; the CLI writes the research record after this answer.
3378
+ const task = Number.isSafeInteger(a.id) && a.id > 0 ? board.get(a.id) : undefined;
3379
+ 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`);
3380
+ return JSON.stringify({ task: task.id, owner: by });
3381
+ }
3382
+ if (op === "task_unassign" && by !== USER) {
3383
+ if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer") throw new PreEffectToolRefusal("role must be owner or reviewer");
3384
+ if (a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("revision must be a nonnegative integer");
3385
+ return line(await tasks.unassign(a.id, by, true, a.role, a.revision));
3386
+ }
3387
+ if (by !== USER && !(Object.hasOwn(CONDUCTOR_TASK_OPS, op) && operationalContext.getStore()?.actor === by)) throw new Error(`${op} is a console command`);
2951
3388
  switch (op) {
2952
3389
  case "release_new":
2953
3390
  return JSON.stringify(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order }));
3391
+ case "claim_verdict": // #901: a person's verdict on one declared claim; the reply names the slot and the verdict only, never a claim
3392
+ 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
3393
  case "github_import":
2955
- return JSON.stringify(await tasks.importGitHub(USER, { repo: a.repo, label: a.label, milestone: a.milestone }));
3394
+ return JSON.stringify(await tasks.importGitHub(by, { repo: a.repo, label: a.label, milestone: a.milestone }));
2956
3395
  case "release_show": {
2957
3396
  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
3397
  if (!release) throw new PreEffectToolRefusal("no such release");
@@ -2968,6 +3407,31 @@ export async function startDaemon(opts: DaemonOptions) {
2968
3407
  case "backlog_pull":
2969
3408
  case "backlog_return":
2970
3409
  return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
3410
+ // #335: the person's schedule commands; #933: the admitted conductor's too. Tasks refuses any other actor.
3411
+ case "schedule_list":
3412
+ if (by !== USER) throw new PreEffectToolRefusal("schedule list is a console command");
3413
+ return JSON.stringify(scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true));
3414
+ case "schedule_run": {
3415
+ // 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
3416
+ // 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.
3417
+ const run = tasks.runScheduleNow(by, schedulesStore, config.schedules, a.id, scheduleNow(), () => opts.schedulesHeld?.() === true);
3418
+ const drained = run.then(() => undefined, () => undefined);
3419
+ manualSchedulePasses.add(drained); void drained.finally(() => manualSchedulePasses.delete(drained));
3420
+ return JSON.stringify(await run);
3421
+ }
3422
+ case "schedule_pause":
3423
+ case "schedule_resume":
3424
+ tasks.setSchedulePaused(by, schedulesStore, config.schedules, a.id, op === "schedule_pause", scheduleNow());
3425
+ return JSON.stringify(scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true).filter(view => view.id === a.id));
3426
+ // #334: natural-language intake. Person only, except approve for the admitted conductor (#933): Tasks refuses any other actor.
3427
+ case "intake": {
3428
+ const result = await tasks.intake(by, { text: a.text, sources: a.sources });
3429
+ return JSON.stringify({ draft: tasks.publicView(result.draft), children: result.children.map(child => tasks.publicView(child)), split: result.split, ...(result.reason ? { reason: result.reason } : {}) });
3430
+ }
3431
+ case "intake_approve":
3432
+ return JSON.stringify((await tasks.approveIntake(by, a.id)).map(task => tasks.publicView(task)));
3433
+ case "intake_discard":
3434
+ return JSON.stringify((await tasks.discardIntake(by, a.id)).map(task => tasks.publicView(task)));
2971
3435
  case "review_brief": {
2972
3436
  const task = board.get(Number(a.id));
2973
3437
  return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
@@ -2976,12 +3440,12 @@ export async function startDaemon(opts: DaemonOptions) {
2976
3440
  const task = board.get(Number(a.id));
2977
3441
  if (!task) return JSON.stringify(`no task #${a.id}`);
2978
3442
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2979
- const codeSymbols = tasks.publicView(task).codeSymbols;
3443
+ const publicTask = tasks.publicView(task), codeSymbols = publicTask.codeSymbols;
2980
3444
  const consoleDeliverables = await tasks.deliverableStatus(task);
2981
3445
  const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
2982
3446
  const fresh = board.get(task.id);
2983
3447
  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);
3448
+ 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
3449
  }
2986
3450
  case "deliverables": {
2987
3451
  // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
@@ -3022,7 +3486,7 @@ export async function startDaemon(opts: DaemonOptions) {
3022
3486
  if (workspace !== undefined) {
3023
3487
  const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
3024
3488
  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);
3489
+ const validated = resolveExecutionCwd(turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
3026
3490
  const bound = executionOwners.get(turn.peer);
3027
3491
  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
3492
  }
@@ -3115,6 +3579,33 @@ export async function startDaemon(opts: DaemonOptions) {
3115
3579
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
3116
3580
  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
3581
  let stopping = false;
3582
+ const roomChat = new RoomChat({ bus, cwd: opts.cwd, routing: activeRouting, piiPaths: () => config.pii?.paths ?? [],
3583
+ senderPrivate: peer => peer === USER ? false : bus.peers.has(peer) && !peer.includes("@") ? !!turns.get(peer)?.private || contextPii(peer) : undefined,
3584
+ taskPrivate: id => { const task = board.get(id); return task ? tasks.isPii(task) : undefined; },
3585
+ modelScreenOn: () => tasks.screenOn(),
3586
+ screen: opts.piiScreen ?? (text => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
3587
+ policyVersion: () => configRevision,
3588
+ limits: () => config.limits,
3589
+ 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}`),
3590
+ roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
3591
+ changed: () => { if (ready && !stopping) writeStatus(); },
3592
+ });
3593
+ remotePending = room => roomChat.pendingIds(room);
3594
+ remoteQueueChanged = () => roomChat.queueChanged();
3595
+ const roomCommands = new RoomCommands({ stateDir: opts.stateDir, cwd: opts.cwd,
3596
+ presence: () => [...bus.peers].filter(([id]) => !id.includes("@") && membershipAllowed(id)).map(([id, peer]) => ({ id, state: peer.state })), chat: roomChat,
3597
+ });
3598
+ // Room crypto/state failure disables only this optional outbound service, never local work.
3599
+ void roomCommands.start().catch(() => log("rooms unavailable"));
3600
+ let roomInboxExpiryFailed = false;
3601
+ const roomInboxExpiry = setInterval(() => {
3602
+ if (stopping || recoveryActive() || bus.isRecoveryHeld) return;
3603
+ try { roomCommands.expireInbox(); roomInboxExpiryFailed = false; }
3604
+ catch { if (!roomInboxExpiryFailed) log("room inbox expiry unavailable"); roomInboxExpiryFailed = true; }
3605
+ }, 30_000);
3606
+ roomInboxExpiry.unref?.(); intervals.push(roomInboxExpiry);
3607
+ const roomTickets = new WeakMap<Sock, { id: string; expires: number; preview: RoomPreview }>();
3608
+
3118
3609
  const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
3119
3610
  docs: opts.releaseDocEvidence,
3120
3611
  gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
@@ -3165,9 +3656,11 @@ export async function startDaemon(opts: DaemonOptions) {
3165
3656
  };
3166
3657
  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
3658
  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;
3659
+ 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
3660
  if (checkPending.size !== 0) return false;
3170
3661
  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
3662
+ if (tasks.queuePressureRechecksPending() !== 0) return false;
3663
+ 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
3664
  if (!recoveryPeerSnapshot) return true;
3172
3665
  const current = recoveryPeers();
3173
3666
  return recoveryPeerSnapshot.every((saved) => {
@@ -3288,7 +3781,14 @@ export async function startDaemon(opts: DaemonOptions) {
3288
3781
  } };
3289
3782
  const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
3290
3783
  claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
3291
- const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
3784
+ const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...new Set([...bus.peers.keys(), ...allExternalInstanceIds()])].filter(id => !id.includes("@")).map(id => {
3785
+ const peer = bus.peers.get(id);
3786
+ if (isExternalInstance(id) || externalRefusedRows.has(id)) {
3787
+ const binding = externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id), receipt = peer && externalLaunches.get(id) ? managedAcpLaunch(peer, externalLaunches.get(id)!.options) : undefined;
3788
+ const saved = restored?.peers.find(row => row.id === id);
3789
+ 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 } : {}) }];
3790
+ }
3791
+ if (!peer) throw new Error("recovery peer disappeared");
3292
3792
  const metadata = peer.recoveryMetadata?.() ?? {};
3293
3793
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
3294
3794
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
@@ -3310,7 +3810,7 @@ export async function startDaemon(opts: DaemonOptions) {
3310
3810
  return row;
3311
3811
  });
3312
3812
  const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
3313
- return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
3813
+ 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
3814
  };
3315
3815
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
3316
3816
  const integrityAs = (expected: unknown) => {
@@ -3358,11 +3858,11 @@ export async function startDaemon(opts: DaemonOptions) {
3358
3858
  };
3359
3859
  const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
3360
3860
  const reservations = board.statusReservations();
3361
- const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
3861
+ const ids = new Set([...bus.knownPeers(), ...extraIds, ...allExternalInstanceIds(), ...reservations].filter(id => !id.includes("@")));
3362
3862
  for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
3363
3863
  if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
3364
3864
  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));
3865
+ 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
3866
  };
3367
3867
  let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
3368
3868
  const checkoutBranch = () => {
@@ -3372,20 +3872,25 @@ export async function startDaemon(opts: DaemonOptions) {
3372
3872
  }
3373
3873
  return checkoutInTick.branch;
3374
3874
  };
3375
- const status = (includeAttentionItems = false, includeAttentionInput = false) => {
3875
+ const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
3376
3876
  const taskNeeds = board.statusTasks();
3377
- const taskStalls = tasks.stalled();
3877
+ const ownerIsolation = ownerIsolationProjection();
3878
+ const taskStalls = tasks.stalled({ ownerIsolation });
3378
3879
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
3379
3880
  const peerIds = byKind(statusPeerIds(taskNeeds));
3881
+ const queueBackpressure = tasks.queuePressureStatus(peerIds);
3380
3882
  const view = {
3883
+ ...(queueBackpressure ? { queueBackpressure } : {}),
3381
3884
  ...(crashReport.length ? { crash: crashReport } : {}),
3382
3885
  projectId,
3383
3886
  instanceId,
3887
+ rooms: roomCommands.statusView(),
3384
3888
  version: VERSION,
3385
3889
  pluginBundle: SERVER_JS,
3386
3890
  pendingRestart: allPendingRestart(),
3387
3891
  retainedConfig,
3388
3892
  ...(agentDrift.length ? { agentDrift } : {}),
3893
+ ...(config.schedules.length ? { schedules: scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true) } : {}),
3389
3894
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3390
3895
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3391
3896
  memoryProfile: memoryProfileStatus(includeAttentionItems),
@@ -3399,13 +3904,21 @@ export async function startDaemon(opts: DaemonOptions) {
3399
3904
  features: DAEMON_FEATURES,
3400
3905
  configuredAdapters: configuredIds,
3401
3906
  ...(externalAdapters.length ? { externalAdapters } : {}),
3907
+ ...(allExternalInstanceIds().length ? { externalInstanceCatalog: externalInstanceCatalog({ schema: 1, projectRoot: opts.cwd, epoch: instanceId,
3908
+ fingerprint: digest(allExternalInstanceIds().map(id => [id, externalBindings.get(id) ?? null])), instances: allExternalInstanceIds().map(id => {
3909
+ const refused = externalRefusedRows.get(id);
3910
+ if (refused) return { id, kind: refused.kind, displayName: refused.kind, roles: [], form: "headless", readOnly: false, permissionState: "unknown", availability: "refused", refusalCode: refused.refusalCode };
3911
+ const bound = retainedExternalBinding(id), current = currentExecutionOwner(id), owner = bus.peers.get(id);
3912
+ return { id, kind: declarations[id]!.kind, displayName: definitionOf(id)!.displayName, roles: config.roles[id] ?? [], form: "headless", readOnly: readOnlyPeer(id), permissionState: owner?.permissionModeState ?? "unknown",
3913
+ availability: !bound ? "refused" : current ? "ready" : "offline", ...(!bound ? { refusalCode: "binding-changed" } : !owner ? { refusalCode: "not-started" } : !current ? { refusalCode: "ownership-unverified" } : {}) };
3914
+ }) }) } : {}),
3402
3915
  ...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
3403
3916
  startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
3404
3917
  readOnlyPeers: extraIds.filter(readOnlyPeer),
3405
3918
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
3406
3919
  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"])),
3920
+ ownerIsolation,
3921
+ 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
3922
  stopping,
3410
3923
  pid: process.pid,
3411
3924
  cwd: opts.cwd,
@@ -3413,12 +3926,12 @@ export async function startDaemon(opts: DaemonOptions) {
3413
3926
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3414
3927
  codexProxyPort: opts.codexProxyPort,
3415
3928
  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 ?? [] } : {}) }]; }),
3929
+ 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
3930
  ),
3418
3931
  taskWip: tasks.wipBreakdown(peerIds),
3419
3932
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
3420
3933
  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 } : {}) }])),
3934
+ roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...roleVisibility(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
3422
3935
  planner: { peers: plannerPeers(), source: plannerSource },
3423
3936
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
3424
3937
  ...(modelRelay ? { models: modelRelay.status() } : {}),
@@ -3429,6 +3942,8 @@ export async function startDaemon(opts: DaemonOptions) {
3429
3942
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
3430
3943
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
3431
3944
  };
3945
+ // #878: live readers only (the status reply, the dashboard); writeStatus never asks, so status.json never holds it.
3946
+ if (includeDoing) for (const [id, doing] of Object.entries(doingRows(peerIds))) Object.assign(view.peers[id]!, { doing });
3432
3947
  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
3948
  // Lazy legacy peer lists need not contain a persisted owner that is now offline.
3434
3949
  for (const task of taskNeeds) if (task.owner && task.owner !== USER && task.owner !== HUB && !Object.hasOwn(peerNeeds, task.owner)) {
@@ -3498,8 +4013,10 @@ export async function startDaemon(opts: DaemonOptions) {
3498
4013
  }
3499
4014
  const noticedHolds = new Set<string>();
3500
4015
  let reportingQueues = false;
4016
+ let queuePressureStatusQueued = false;
3501
4017
  bus.onQueues = () => {
3502
4018
  if (stopping || settlingSuperseded || reportingQueues) return;
4019
+ remoteQueueChanged();
3503
4020
  reportingQueues = true;
3504
4021
  try {
3505
4022
  settleSupersededDeliveries();
@@ -3511,6 +4028,18 @@ export async function startDaemon(opts: DaemonOptions) {
3511
4028
  }
3512
4029
  writeStatus();
3513
4030
  } finally { reportingQueues = false; }
4031
+ if (ready) {
4032
+ tasks.scheduleQueuePressureRecheck();
4033
+ const policy = queuePolicy();
4034
+ if ((policy.max_queued > 0 || policy.max_oldest_s > 0) && !queuePressureStatusQueued) {
4035
+ queuePressureStatusQueued = true;
4036
+ // withdraw() notifies before its synchronous checkpoint. Refresh only after it commits.
4037
+ queueMicrotask(() => {
4038
+ queuePressureStatusQueued = false;
4039
+ if (!stopping) try { writeStatus(); } catch { log("queue pressure status unavailable"); }
4040
+ });
4041
+ }
4042
+ }
3514
4043
  };
3515
4044
  // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
3516
4045
  settleSupersededDeliveries();
@@ -3694,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
3694
4223
  if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
3695
4224
  piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
3696
4225
  }
3697
- const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
4226
+ const peers = [...bus.peers.values()].filter((p) => p.state !== "offline" && !p.id.includes("@")).map((p) => {
3698
4227
  let meta: Record<string, unknown> = {};
3699
4228
  try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
3700
4229
  return { peer: p.id, meta };
@@ -3714,6 +4243,7 @@ export async function startDaemon(opts: DaemonOptions) {
3714
4243
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
3715
4244
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
3716
4245
  for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
4246
+ if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`); continue; }
3717
4247
  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
4248
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
3719
4249
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
@@ -3750,8 +4280,11 @@ export async function startDaemon(opts: DaemonOptions) {
3750
4280
  uiEvents.push({ seq: ++uiSequence, event: e });
3751
4281
  if (uiEvents.length > 200) uiEvents.shift();
3752
4282
  if (e.t === "state") {
4283
+ roomCommands.changed();
3753
4284
  log(`state ${e.peer} -> ${e.state}`);
3754
4285
  event({ type: "state", peer: e.peer, state: e.state });
4286
+ if (ready) tasks.scheduleQueuePressureRecheck();
4287
+ if (e.peer.includes("@")) { if (!stopping) writeStatus(); return; }
3755
4288
  // #248: the add-ons it works with, once per attach (names and versions only).
3756
4289
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
3757
4290
  // still did its work under that attachment.
@@ -3859,7 +4392,11 @@ export async function startDaemon(opts: DaemonOptions) {
3859
4392
  }
3860
4393
  }
3861
4394
  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}`); }
4395
+ // #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:
4396
+ // 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.
4397
+ let unobserved: string[] | undefined;
4398
+ if (open.tree && end.tree && end.repo) try { unobserved = dirtySubmodules(end.repo, end.budget); } catch (error) { snapshotFailure(`the submodules of ${open.id}`, error); }
4399
+ 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
4400
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
3864
4401
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
3865
4402
  }
@@ -3958,7 +4495,10 @@ export async function startDaemon(opts: DaemonOptions) {
3958
4495
  const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
3959
4496
  type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
3960
4497
  async function onPermission(req: PermissionRequest, signal?: AbortSignal): Promise<string | undefined> {
4498
+ const external = isExternalInstance(req.peer) ? currentExecutionOwner(req.peer, true) : undefined;
4499
+ if (isExternalInstance(req.peer) && !external) return undefined;
3961
4500
  const result = await requestPermission(req, signal);
4501
+ if (external && (currentExecutionOwner(req.peer, true) !== external || !membershipAllowed(req.peer))) return undefined;
3962
4502
  return result.outcome === "answered" ? result.optionId : undefined;
3963
4503
  }
3964
4504
  /** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
@@ -3983,7 +4523,7 @@ export async function startDaemon(opts: DaemonOptions) {
3983
4523
  }
3984
4524
  return !gate.denyOnly && picked.optionId === "allow";
3985
4525
  }
3986
- async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false): Promise<PermissionResult> {
4526
+ async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false, lifecycle?: { registered(id: string): void; expired(id: string): void }): Promise<PermissionResult> {
3987
4527
  const requestEpoch = permissionAnswerEpoch(req.peer);
3988
4528
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
3989
4529
  try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
@@ -4021,6 +4561,7 @@ export async function startDaemon(opts: DaemonOptions) {
4021
4561
  signal?.removeEventListener("abort", aborted);
4022
4562
  permissions.delete(id); // withdrawal happens before any observer, resolution or late grant
4023
4563
  writeStatus(); // token-free readers must observe withdrawal before settlement callbacks
4564
+ if (!option && reason === "expired") { try { lifecycle?.expired(id); } catch { /* Host diagnostics never change approval settlement. */ } }
4024
4565
  const outcome = option ? "answered" : reason;
4025
4566
  if (surface) permissionAnswerEpochs.set(req.peer, permissionAnswerEpoch(req.peer) + 1);
4026
4567
  observed(surface ? "person" : outcome === "expired" ? "expired" : "aborted");
@@ -4034,6 +4575,7 @@ export async function startDaemon(opts: DaemonOptions) {
4034
4575
  return true;
4035
4576
  };
4036
4577
  permissions.set(id, { push, done, peer: req.peer, ...(publicTool ? { tool: publicTool } : {}), createdAt, expiresAt });
4578
+ try { lifecycle?.registered(id); } catch { /* Host diagnostics never change approval admission. */ }
4037
4579
  writeStatus(); // an approval can wait without any subsequent bus event
4038
4580
  timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
4039
4581
  signal?.addEventListener("abort", aborted, { once: true });
@@ -4058,8 +4600,13 @@ export async function startDaemon(opts: DaemonOptions) {
4058
4600
  replacingPi.add(pi);
4059
4601
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
4060
4602
  }
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>> {
4603
+ 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>> {
4604
+ startCurrent?.();
4605
+ if (startCurrent && starting.has(peer)) return Promise.resolve({ ok: false, error: "controlled recovery cannot join another pending start" });
4606
+ if (peer.includes("@")) return Promise.resolve({ ok: false, error: "remote peers have no local runtime or control authority" });
4607
+ 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
4608
  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" });
4609
+ 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
4610
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
4064
4611
  if (!membershipAllowed(peer)) return Promise.resolve({ ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` });
4065
4612
  if (rosterChanging === peer) return Promise.resolve({ ok: false, error: `${peer} roster change is in progress` });
@@ -4072,11 +4619,13 @@ export async function startDaemon(opts: DaemonOptions) {
4072
4619
  return joined;
4073
4620
  }
4074
4621
  if (definitionOf(peer)?.permission.trackUnattended) startingUnattended.set(peer, args.unattended === true);
4075
- const running = startPeerOnce(peer, args).then(async result => {
4622
+ const running = startPeerOnce(peer, args, startCurrent).then(async result => {
4623
+ startCurrent?.();
4076
4624
  if (!result.ok && definitionOf(peer)?.permission.trackUnattended) unattendedPeers.delete(peer);
4077
4625
  if (!result.ok || result.already) return result;
4078
4626
  if (definitionOf(peer)?.session.restoreOnStart) recordSessions();
4079
4627
  return permissionChange(peer, async () => {
4628
+ startCurrent?.();
4080
4629
  if (definitionOf(peer)?.permission.trackUnattended) { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
4081
4630
  logPermissionStart(peer); writeStatus(); return result;
4082
4631
  }, true);
@@ -4132,7 +4681,9 @@ export async function startDaemon(opts: DaemonOptions) {
4132
4681
  });
4133
4682
  // Printed for a person's explicit reconciliation only; never executed by auto-start.
4134
4683
  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>> {
4684
+ async function hubStart(peer: unknown, by: string, person = false): Promise<Record<string, unknown>> {
4685
+ if (typeof peer === "string" && peer.includes("@")) return { ok: false, error: "remote peers have no local runtime or control authority" };
4686
+ if (typeof peer === "string" && isExternalInstance(peer) && !person) return { ok: false, error: "external module instances require an explicit person start" };
4136
4687
  if (stopping) return { ok: false, error: "hub is stopping" };
4137
4688
  if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
4138
4689
  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 +4763,7 @@ export async function startDaemon(opts: DaemonOptions) {
4212
4763
  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
4764
  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
4765
  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` };
4766
+ 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
4767
  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
4768
  requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
4218
4769
  try {
@@ -4230,7 +4781,7 @@ export async function startDaemon(opts: DaemonOptions) {
4230
4781
  await owner.stop(reason);
4231
4782
  writeStatus();
4232
4783
  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" };
4784
+ 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
4785
  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
4786
  return { ok: true, state };
4236
4787
  });
@@ -4257,31 +4808,35 @@ export async function startDaemon(opts: DaemonOptions) {
4257
4808
  };
4258
4809
  }
4259
4810
 
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>> {
4811
+ 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>> {
4812
+ startCurrent?.();
4261
4813
  let unmute: (() => void) | undefined;
4262
4814
  try {
4815
+ 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
4816
  if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
4264
4817
  let executionCwd: string;
4265
- try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
4818
+ try { executionCwd = resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
4266
4819
  catch (error) { return { ok: false, error: (error as Error).message }; }
4267
4820
  executionRoots.set(peer, executionCwd);
4268
4821
  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);
4822
+ if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); startCurrent?.(); await refreshRunner(); startCurrent?.(); }
4823
+ const result = await startPeerBody(peer, args, (p) => { unmute = muteState(p); }, executionCwd, startCurrent);
4824
+ startCurrent?.();
4271
4825
  return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
4272
4826
  } finally {
4273
4827
  unmute?.();
4274
4828
  }
4275
4829
  }
4276
4830
 
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>> {
4831
+ 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>> {
4832
+ startCurrent?.();
4278
4833
  const adapter = definitionOf(peer);
4279
4834
  if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
4280
4835
  && (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
4281
4836
  return { ok: false, error: "configured ACP session selector is invalid" };
4282
4837
  }
4283
4838
  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" };
4839
+ 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
4840
  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
4841
  if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
4287
4842
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
@@ -4326,7 +4881,7 @@ export async function startDaemon(opts: DaemonOptions) {
4326
4881
  recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
4327
4882
  args = { ...args, ...selected };
4328
4883
  mute(existing);
4329
- await stopPiForReplacement(existing);
4884
+ await stopPiForReplacement(existing); startCurrent?.();
4330
4885
  }
4331
4886
  if (isPiPeer(existing) && !args.replaceSession) {
4332
4887
  let saved = existing.recoveryMetadata();
@@ -4343,18 +4898,18 @@ export async function startDaemon(opts: DaemonOptions) {
4343
4898
  // process from the abandoned CLI cannot claim the replacement owner.
4344
4899
  // The replacement's start event is the only state change the console should see (issue #42).
4345
4900
  mute(existing);
4346
- await stopPiForReplacement(existing);
4901
+ await stopPiForReplacement(existing); startCurrent?.();
4347
4902
  } else if (changesOwner) {
4348
- saved = await existing.captureResume();
4903
+ saved = await existing.captureResume(); startCurrent?.();
4349
4904
  if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
4350
4905
  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
4906
  // Same as the unclaimed handover: no offline flash between the adapters (issue #42).
4352
4907
  mute(existing);
4353
- await stopPiForReplacement(existing);
4908
+ await stopPiForReplacement(existing); startCurrent?.();
4354
4909
  } else if (existing.state !== "offline") {
4355
4910
  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
4911
  } else if (saved.sessionId) {
4357
- saved = await existing.captureResume();
4912
+ saved = await existing.captureResume(); startCurrent?.();
4358
4913
  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
4914
  }
4360
4915
  }
@@ -4371,6 +4926,7 @@ export async function startDaemon(opts: DaemonOptions) {
4371
4926
  if (!adapter?.start.reconfigureWhileAttached) {
4372
4927
  if (isPiPeer(existing)) await stopPiForReplacement(existing);
4373
4928
  else await existing?.stop();
4929
+ startCurrent?.();
4374
4930
  }
4375
4931
  if (adapter?.start.createTui && args.mode === "tui") {
4376
4932
  // #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 +4935,7 @@ export async function startDaemon(opts: DaemonOptions) {
4379
4935
  if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4380
4936
  // The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
4381
4937
  const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
4938
+ startCurrent?.();
4382
4939
  if (!verdict.ok) return { ok: false, error: verdict.reason };
4383
4940
  if (verdict.note) log(verdict.note);
4384
4941
  if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
@@ -4386,6 +4943,7 @@ export async function startDaemon(opts: DaemonOptions) {
4386
4943
  const nativeRoot = nativeStateDir(peer);
4387
4944
  mkdirSync(nativeRoot, { recursive: true });
4388
4945
  const qwenState = join(nativeRoot, "qwen");
4946
+ startCurrent?.();
4389
4947
  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
4948
  cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
4391
4949
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
@@ -4400,10 +4958,10 @@ export async function startDaemon(opts: DaemonOptions) {
4400
4958
  onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
4401
4959
  log,
4402
4960
  }) }) as QwenDualPeer;
4403
- recoveryTaskPreface(peer); await ensurePreface(peer);
4961
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
4404
4962
  executionOwners.set(peer, { owner: tui, cwd: executionCwd });
4405
4963
  try {
4406
- await tui.start();
4964
+ startCurrent?.(); await tui.start(); startCurrent?.();
4407
4965
  bus.add(tui);
4408
4966
  if (tui.state !== "offline") tui.onState?.(tui.state);
4409
4967
  } catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
@@ -4413,6 +4971,7 @@ export async function startDaemon(opts: DaemonOptions) {
4413
4971
  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
4972
  // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
4415
4973
  const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
4974
+ startCurrent?.();
4416
4975
  if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
4417
4976
  if (jcodeVerdict.note) log(jcodeVerdict.note);
4418
4977
  // #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 +4979,36 @@ export async function startDaemon(opts: DaemonOptions) {
4420
4979
  // #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
4421
4980
  // written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
4422
4981
  if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
4982
+ startCurrent?.();
4423
4983
  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
4984
  providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
4425
4985
  ...(instance?.provider ? { provider: instance.provider } : {}),
4426
4986
  ...(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;
4987
+ preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
4988
+ // #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
4989
+ // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
4990
+ onUsageDiagnostic: (observation, sessionId) => {
4991
+ if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
4992
+ const current = contextSession(peer); if (!current) return;
4993
+ const reading = readNativeContext(adapter, { used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
4994
+ if (reading) contexts.report(peer, reading, current);
4995
+ },
4996
+ // 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.
4997
+ onTurnUsage: (usage) => {
4998
+ if (bus.peers.get(peer) !== jcode) return; // a replaced adapter's late result is not this peer's
4999
+ event({ type: "usage", peer, source: "jcode_acp", id: `jcode:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens,
5000
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), ...taskAttribution(peer) });
5001
+ } }) }) as JcodePeer;
4428
5002
  jcodeLaunches.set(peer, jcode);
4429
- recoveryTaskPreface(peer); await ensurePreface(peer);
4430
- try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
5003
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
5004
+ try { startCurrent?.(); await jcode.start(); startCurrent?.(); await reconcileNativeStart(peer, jcode, startCurrent); }
4431
5005
  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
5006
  return { ok: true, policy: "unmanaged" };
4433
5007
  }
4434
5008
  if (adapter?.start.acpCommand) {
4435
5009
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
4436
5010
  actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
5011
+ startCurrent?.();
4437
5012
  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
5013
  onActivity: item => activityRings[peer]?.append(item),
4439
5014
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
@@ -4474,10 +5049,10 @@ export async function startDaemon(opts: DaemonOptions) {
4474
5049
  preamble: sessionRoleContract(peer),
4475
5050
  }) }) as AcpPeer;
4476
5051
  recoveryTaskPreface(peer);
4477
- await ensurePreface(peer);
5052
+ await ensurePreface(peer); startCurrent?.();
4478
5053
  try {
4479
- await kimi.start();
4480
- await reconcileNativeStart(peer, kimi);
5054
+ startCurrent?.(); await kimi.start(); startCurrent?.();
5055
+ await reconcileNativeStart(peer, kimi, startCurrent);
4481
5056
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
4482
5057
  return { ok: true };
4483
5058
  }
@@ -4488,12 +5063,17 @@ export async function startDaemon(opts: DaemonOptions) {
4488
5063
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
4489
5064
  const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
4490
5065
  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: {
5066
+ const externalBinding = isExternalInstance(peer) ? captureExternalBinding(peer, args.sessionId) : undefined;
5067
+ const externalWorkspace = externalBinding ? externalWorkspaceIdentity(executionCwd) : undefined;
5068
+ if (externalBinding && permission !== "read-only" && defaultSources[peer] && !adapter.permission.genericAcp?.modes?.[permission]) throw new Error("external instance configured permission mode has no native mapping");
5069
+ const externalLaunchId = externalBinding ? randomUUID() : undefined;
5070
+ if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
5071
+ const acpOptions: AcpOptions = {
4492
5072
  onActivity: item => activityRings[peer]?.append(item),
4493
5073
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4494
5074
  actionGate: acpActionGate(peer),
4495
5075
  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 ?? {} } : {}),
5076
+ ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: { ...(adapter.permission.genericAcp ?? {}), ...(readOnlyPeer(peer) ? { lockedReadOnly: true } : {}) } } : {}),
4497
5077
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
4498
5078
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
4499
5079
  autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
@@ -4508,13 +5088,37 @@ export async function startDaemon(opts: DaemonOptions) {
4508
5088
  onTurnFailure: () => { supervisionTurns.delete(peer); },
4509
5089
  mcpServers,
4510
5090
  preamble: sessionRoleContract(peer),
4511
- } } });
5091
+ };
5092
+ if (externalBinding) {
5093
+ if (acpOptions.nativePolicy?.modes) Object.freeze(acpOptions.nativePolicy.modes);
5094
+ if (acpOptions.nativePolicy) Object.freeze(acpOptions.nativePolicy);
5095
+ 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);
5096
+ externalInstanceStateDir(opts.stateDir, peer);
5097
+ }
5098
+ startCurrent?.();
5099
+ const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, ...(externalBinding ? { instanceStateDir: nativeStateDir(peer) } : {}), kindConfig: undefined, permission, mcpServers, daemon: { acp: acpOptions } });
5100
+ if (externalBinding && externalLaunchId) externalLaunches.set(peer, { owner, options: acpOptions, launchId: externalLaunchId, binding: externalBinding, workspace: externalWorkspace! });
5101
+ assertNativeProofPorts(adapter, owner);
4512
5102
  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); }
5103
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
5104
+ try {
5105
+ if (externalBinding && (!membershipAllowed(peer) || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || !sameExternalInstanceBinding(externalBinding, captureExternalBinding(peer, args.sessionId)))) throw new Error("external instance binding changed before native spawn");
5106
+ startCurrent?.(); await owner.start(); startCurrent?.();
5107
+ if (externalBinding) {
5108
+ if (!await verifyManagedAcpLaunch(owner, acpOptions)) throw new Error("external ACP managed launch is unverified");
5109
+ startCurrent?.();
5110
+ const receipt = managedAcpLaunch(owner, acpOptions), current = captureExternalBinding(peer, args.sessionId);
5111
+ if (!receipt || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || receipt.cwd !== executionCwd || !sameExternalInstanceBinding(externalBinding, current)) throw new Error("external instance startup binding changed");
5112
+ externalLaunches.get(peer)!.generation = receipt.generation;
5113
+ externalBindings.set(peer, externalBinding);
5114
+ externalSessions.set(peer, receipt.sessionId);
5115
+ writeExternalInstanceRecord(opts.stateDir, externalBinding, receipt.sessionId);
5116
+ }
5117
+ await reconcileNativeStart(peer, owner, startCurrent);
5118
+ }
4515
5119
  catch (error) {
4516
5120
  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.
5121
+ 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
5122
  if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
4519
5123
  if (error instanceof QwenIdentityError) throw error;
4520
5124
  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 +5129,11 @@ export async function startDaemon(opts: DaemonOptions) {
4525
5129
  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
5130
  let codexSteering = false;
4527
5131
  actionBudgets.revoke(peer);
5132
+ startCurrent?.();
4528
5133
  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
5134
  admitAttach: () => membershipAllowed(peer),
4530
5135
  permissionMode: permissionMode(peer),
5136
+ ...(declarations[peer] ? { threadSandbox: () => isPlannerOnly(config.roles[peer] ?? []) ? declarations[peer]?.worktree ? "unavailable" as const : "read-only" as const : undefined } : {}),
4531
5137
  actionGate: acpActionGate(peer), onPermission,
4532
5138
  onTokens: (added) => void addTokens(peer, added),
4533
5139
  // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
@@ -4593,10 +5199,10 @@ export async function startDaemon(opts: DaemonOptions) {
4593
5199
  log,
4594
5200
  }) }) as CodexPeer;
4595
5201
  recoveryTaskPreface(peer);
4596
- await ensurePreface(peer);
5202
+ await ensurePreface(peer); startCurrent?.();
4597
5203
  try {
4598
- await codex.start();
4599
- await reconcileNativeStart(peer, codex);
5204
+ startCurrent?.(); await codex.start(); startCurrent?.();
5205
+ await reconcileNativeStart(peer, codex, startCurrent);
4600
5206
  } catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
4601
5207
  const proxy = frontendProxyState(peer, codex);
4602
5208
  if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
@@ -4621,6 +5227,7 @@ export async function startDaemon(opts: DaemonOptions) {
4621
5227
  },
4622
5228
  });
4623
5229
  modelRelays.set(peer, modelRelay);
5230
+ startCurrent?.();
4624
5231
  if (!declarations[peer]) globalModelRelay(modelRelay);
4625
5232
  const nativeRoot = nativeStateDir(peer);
4626
5233
  mkdirSync(nativeRoot, { recursive: true });
@@ -4684,6 +5291,7 @@ export async function startDaemon(opts: DaemonOptions) {
4684
5291
  },
4685
5292
  };
4686
5293
  const routing = activeRouting();
5294
+ startCurrent?.();
4687
5295
  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
5296
  cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
4689
5297
  model: args.model,
@@ -4793,11 +5401,11 @@ export async function startDaemon(opts: DaemonOptions) {
4793
5401
  watchdogMs: config.watchdog_ms, maxSteps: config.pi.max_steps, log,
4794
5402
  }) }) as PiPeer;
4795
5403
  recoveryTaskPreface(peer);
4796
- await ensurePreface(peer);
5404
+ await ensurePreface(peer); startCurrent?.();
4797
5405
  executionOwners.set(peer, { owner: pi, cwd: executionCwd });
4798
5406
  bus.add(pi);
4799
5407
  // 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) {
5408
+ try { startCurrent?.(); await pi.start(); } catch (error) {
4801
5409
  await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
4802
5410
  if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
4803
5411
  throw error;
@@ -4814,9 +5422,10 @@ export async function startDaemon(opts: DaemonOptions) {
4814
5422
  // It stays held; holdPeer validates the restored choice before an operator can resume it.
4815
5423
  if (recoveryActive() && manualPaused.has(peer)) log("local: availability deferred while controlled recovery retains its manual pause");
4816
5424
  else await validateLocalChoice(route, fixedModel);
5425
+ startCurrent?.();
4817
5426
  // Validation can await network: a queued delivery may have started meanwhile.
4818
5427
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
4819
- if (existing) { mute(existing); await existing.stop(); }
5428
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
4820
5429
  const capture = config.memory.enabled
4821
5430
  ? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
4822
5431
  : undefined;
@@ -4858,6 +5467,7 @@ export async function startDaemon(opts: DaemonOptions) {
4858
5467
  shellProofs.ordinary(input, current);
4859
5468
  return current() ? true : "aborted";
4860
5469
  };
5470
+ startCurrent?.();
4861
5471
  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
5472
  cwd: executionCwd,
4863
5473
  omni,
@@ -4872,7 +5482,11 @@ export async function startDaemon(opts: DaemonOptions) {
4872
5482
  turnId: () => turns.get(peer)?.id,
4873
5483
  onTool: (observation, task) => observeProgress(peer, observation, task),
4874
5484
  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) },
5485
+ tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, readOnly: config.data?.readonly, permit, shellExecutionCheck: input => shellProofs.check(input),
5486
+ // #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).
5487
+ // #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.
5488
+ ...(webEgress ? { webFetch: { allow: webAllow, proxyUrl: () => webEgress?.url, ...(opts.webFetchTransport ? { fetch: opts.webFetchTransport } : {}) } } : {}),
5489
+ ...(config.local.doc_read === true ? { docRead: { projections: () => projectionsFor(peer), piiPaths: () => config.pii?.paths ?? [], piiPatterns: () => activeRouting().signals.pii_patterns } } : {}) },
4876
5490
  onApprovalStop: reason => notify(reason),
4877
5491
  ...(capture ? { capture } : {}),
4878
5492
  profile: resolvedProfile,
@@ -4890,24 +5504,25 @@ export async function startDaemon(opts: DaemonOptions) {
4890
5504
  log,
4891
5505
  }) }) as LocalPeer;
4892
5506
  recoveryTaskPreface(peer);
4893
- await ensurePreface(peer);
5507
+ await ensurePreface(peer); startCurrent?.();
4894
5508
  executionOwners.set(peer, { owner: local, cwd: executionCwd });
4895
5509
  bus.add(local);
4896
- await local.start();
5510
+ startCurrent?.(); await local.start();
4897
5511
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
4898
5512
  }
4899
5513
  if (adapter?.start.daemonPort === "claude-implementer") {
4900
5514
  if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
4901
5515
  const currentRoot = () => {
4902
5516
  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;
5517
+ 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
5518
  };
4905
5519
  try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
4906
5520
  catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
4907
5521
  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"); }
5522
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
4909
5523
  const selected = permissionMode(peer);
4910
5524
  if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
5525
+ startCurrent?.();
4911
5526
  const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4912
5527
  cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
4913
5528
  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 +5535,25 @@ export async function startDaemon(opts: DaemonOptions) {
4920
5535
  } catch { return false; }
4921
5536
  },
4922
5537
  chatTask: () => implementerChatTask(peer),
5538
+ failureTrace: (launch, nativeVersion) => {
5539
+ const trace = createClaudeFailureTrace({ instanceId, nativeVersion, current: () => {
5540
+ const active = implementer.ownedTools?.(launch.launchId);
5541
+ return bus.peers.get(peer) === implementer && implementerFailures.get(implementer) === entry
5542
+ && active?.generation === launch.generation && active.signal === launch.signal && !launch.signal.aborted;
5543
+ } });
5544
+ const entry: ImplementerFailureEntry = { launchId: launch.launchId, generation: launch.generation, signal: launch.signal, trace, probeCount: 0, permissionAttempt: 0 };
5545
+ implementerFailures.set(implementer, entry);
5546
+ return trace;
5547
+ },
5548
+ failurePrivate: taskId => {
5549
+ try {
5550
+ if (bus.peers.get(peer) !== implementer || !turns.has(peer) || contextPii(peer)) return true;
5551
+ if (taskId === undefined) return false;
5552
+ const task = board.get(taskId);
5553
+ return !task || task.owner !== peer || tasks.isPii(task);
5554
+ } catch { return true; }
5555
+ },
5556
+ failureLog: message => log(message),
4923
5557
  transcriptPersisted: (task, session) => {
4924
5558
  const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
4925
5559
  return path ? pathEvidence(path, lstatSync) : "unknown";
@@ -4932,10 +5566,11 @@ export async function startDaemon(opts: DaemonOptions) {
4932
5566
  ...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
4933
5567
  }, notify,
4934
5568
  }) });
4935
- await implementer.start();
5569
+ startCurrent?.(); await implementer.start();
4936
5570
  try {
5571
+ startCurrent?.();
4937
5572
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4938
- recoveryTaskPreface(peer); await ensurePreface(peer);
5573
+ recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
4939
5574
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4940
5575
  executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
4941
5576
  return { ok: true };
@@ -4946,8 +5581,9 @@ export async function startDaemon(opts: DaemonOptions) {
4946
5581
  if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
4947
5582
  // #519: each backend proves isolation before admission, then starts one fresh child per review.
4948
5583
  if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
4949
- if (existing) { mute(existing); await existing.stop(); }
5584
+ if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
4950
5585
  const reviewPolicies = new Map<string, string>();
5586
+ startCurrent?.();
4951
5587
  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
5588
  backend, cwd: executionCwd,
4953
5589
  mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
@@ -4994,9 +5630,10 @@ export async function startDaemon(opts: DaemonOptions) {
4994
5630
  },
4995
5631
  log,
4996
5632
  }) }) as ReviewerPrintPeer;
4997
- try { await reviewer.start(); }
5633
+ try { startCurrent?.(); await reviewer.start(); startCurrent?.(); }
4998
5634
  catch (error) {
4999
5635
  await reviewer.stop().catch(() => {});
5636
+ startCurrent?.();
5000
5637
  // #691: a version refusal names only versions and the trust command, so it is shown for every backend.
5001
5638
  const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
5002
5639
  : reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
@@ -5076,11 +5713,12 @@ export async function startDaemon(opts: DaemonOptions) {
5076
5713
  status: () => {
5077
5714
  const quota = budget.status();
5078
5715
  const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
5716
+ const doing = doingRows(ids);
5079
5717
  return {
5080
5718
  peers: ids.map(peer => {
5081
5719
  const summary = bus.queueSummary(peer);
5082
5720
  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,
5721
+ manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null, doing: doing[peer] ?? null,
5084
5722
  ...(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
5723
  }),
5086
5724
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
@@ -5122,6 +5760,7 @@ export async function startDaemon(opts: DaemonOptions) {
5122
5760
  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
5761
  return {
5124
5762
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
5763
+ ...roomChat.evidence(row.peer, row.id),
5125
5764
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
5126
5765
  createdAt: row.createdAt, updatedAt: row.updatedAt,
5127
5766
  envelopeIds: originals.map((env) => env.id),
@@ -5206,7 +5845,7 @@ export async function startDaemon(opts: DaemonOptions) {
5206
5845
  rows: report.rows.map(row => row.private || privatePrs.has(row.pr) ? { private: true, ready: false, tasks: [], after: [], blockers: ["private lifecycle evidence unavailable"] }
5207
5846
  : mixed ? { pr: row.pr, tasks: row.tasks.filter(id => !privateIds.has(id)), ready: false, after: [], blockers: ["private dependency evidence unavailable"] }
5208
5847
  : { 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 } : {}) }),
5848
+ ...(row.findings ? { findings: row.findings } : {}), ...(row.planConformance ? { planConformance: row.planConformance } : {}), ...(row.testIntegrity ? { testIntegrity: row.testIntegrity } : {}), ...(row.evalGate ? { evalGate: row.evalGate } : {}) }),
5210
5849
  order: mixed ? [] : report.order, ...(report.unordered ? { unordered: mixed ? [] : report.unordered } : {}), conflicts: mixed ? [] : report.conflicts });
5211
5850
  }
5212
5851
  /** A public read may use host GitHub authentication only for this project's current public scope. */
@@ -5218,14 +5857,23 @@ export async function startDaemon(opts: DaemonOptions) {
5218
5857
  || githubOriginRepository(opts.cwd) === identity;
5219
5858
  }
5220
5859
  const publicReadPrivacy = () => JSON.stringify(board.list().map(task => [task.id, tasks.isPii(task), task.history.length]));
5221
- function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
5860
+ function operationalCurrent(sock:Sock,acceptedAfterClose=false,committedRecovery=false):()=>void {
5222
5861
  const client=sock.data,peer=client.peer;
5223
5862
  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");
5863
+ 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
5864
  try {requireConductor(peer,config.roles);}
5226
5865
  catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
5227
5866
  };
5228
5867
  }
5868
+ function currentRecoveryPolicy(peer: string) {
5869
+ const inputs = readRecoveryPolicyInputs(opts.cwd, runningRoutingDefault);
5870
+ const candidate = loadConfig(opts.cwd, inputs.layers, inputs.routing.retired ?? [], []);
5871
+ assertRecoverySourceInputs(acceptedSources.trustLayers, inputs);
5872
+ // Compare accepted declarations; config.roles also contains the settled planner projection.
5873
+ assertRecoveryConfiguration({ ...config, roles: policyRoles }, candidate);
5874
+ assertRecoveryRouting(activeRouting(), inputs.routing);
5875
+ return captureRecoveryPolicy({ root: opts.cwd, id: projectId, stateDir: opts.stateDir }, peer, candidate, inputs);
5876
+ }
5229
5877
  function admitOperational(sock:Sock,command:string):()=>void {
5230
5878
  const current=operationalCurrent(sock);current();
5231
5879
  if(!event({type:"agent_cli",peer:sock.data.peer!,command,refused:false}))throw new Error("CLI audit event was not retained");
@@ -5316,7 +5964,7 @@ export async function startDaemon(opts: DaemonOptions) {
5316
5964
  return ta && tb && CONFLICT_STATES.includes(ta.state) && CONFLICT_STATES.includes(tb.state) ? [{ kind: "conflict" as const, tasks: conflict.tasks, peers: conflict.peers }] : [];
5317
5965
  }),
5318
5966
  ];
5319
- const attentionStatus = status(false, true);
5967
+ const attentionStatus = status(false, true, true);
5320
5968
  let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
5321
5969
  const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
5322
5970
  const usageView = (open: boolean) => {
@@ -5361,11 +6009,14 @@ export async function startDaemon(opts: DaemonOptions) {
5361
6009
  projectId,
5362
6010
  instanceId,
5363
6011
  attention: attentionStatus.attentionInput,
6012
+ rooms: attentionStatus.rooms,
5364
6013
  pet,
5365
- status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
6014
+ status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo, ownerIsolation: attentionStatus.ownerIsolation, ownerIsolationNotes: attentionStatus.ownerIsolationNotes },
5366
6015
  boardCapabilities: { backlog: true },
5367
6016
  planningCapabilities: { enabled: true, wipPull: true, split: true },
5368
6017
  releases: tasks.releaseList(USER),
6018
+ // #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.
6019
+ ...(config.schedules.length ? { schedules: scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true) } : {}),
5369
6020
  boardLegend: Object.entries(peerView).map(([id, peer]) => ({
5370
6021
  peer: id, state: peer.state,
5371
6022
  permissionMode: "permissionNote" in peer && peer.permissionNote ? "unmanaged" : permissionDisplay(id, bus.peers.get(id)),
@@ -5376,7 +6027,7 @@ export async function startDaemon(opts: DaemonOptions) {
5376
6027
  tasks: boardTasks.map((task) => {
5377
6028
  const view = tasks.publicView(task);
5378
6029
  // 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 } : {}; })() };
6030
+ 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
6031
  }),
5381
6032
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
5382
6033
  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 +6210,7 @@ export async function startDaemon(opts: DaemonOptions) {
5559
6210
  const raw = valueAt(next, path.split("."));
5560
6211
  const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
5561
6212
  if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
6213
+ if (raw === undefined && valueAt(layer.raw, path.split(".")) === undefined) continue;
5562
6214
  putConfig(layer.raw, path, raw);
5563
6215
  }
5564
6216
  }
@@ -5568,7 +6220,7 @@ export async function startDaemon(opts: DaemonOptions) {
5568
6220
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
5569
6221
  texts["config.json"] = next["config.json"];
5570
6222
  texts["config.local.json"] = next["config.local.json"];
5571
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
6223
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
5572
6224
  const refusal = prepared.rejected.find(item => item.key === scope);
5573
6225
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
5574
6226
  return prepared.fileConfig;
@@ -5576,7 +6228,7 @@ export async function startDaemon(opts: DaemonOptions) {
5576
6228
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
5577
6229
  const texts = configSourceTexts(opts.cwd);
5578
6230
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
5579
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
6231
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
5580
6232
  const refusal = prepared.rejected.find(item => item.key === scope);
5581
6233
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
5582
6234
  }
@@ -5594,7 +6246,7 @@ export async function startDaemon(opts: DaemonOptions) {
5594
6246
  const texts = configSourceTexts(opts.cwd);
5595
6247
  fingerprint = configFingerprint(texts);
5596
6248
  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);
6249
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
5598
6250
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
5599
6251
  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
6252
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -5620,6 +6272,7 @@ export async function startDaemon(opts: DaemonOptions) {
5620
6272
  config.permission_default_sources = candidate.permission_default_sources;
5621
6273
  for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
5622
6274
  if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
6275
+ if (changes.some(change => change.key === "schedules" || change.key.startsWith("schedules.") || change.key === "folder_triggers" || change.key.startsWith("folder_triggers."))) rescheduleSchedules();
5623
6276
  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
6277
  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
6278
  Object.assign(limits, config.limits);
@@ -5629,6 +6282,7 @@ export async function startDaemon(opts: DaemonOptions) {
5629
6282
  desiredRouting = structuredClone(prepared.routing);
5630
6283
  acknowledgeConfiguration(sources, prepared.accepted);
5631
6284
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
6285
+ if (ready && changes.some(change => change.key === "tasks.queue_backpressure" || change.key.startsWith("tasks.queue_backpressure."))) tasks.scheduleQueuePressureRecheck();
5632
6286
  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
6287
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
5634
6288
  retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
@@ -5768,7 +6422,7 @@ export async function startDaemon(opts: DaemonOptions) {
5768
6422
  await owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
5769
6423
  if (definitionOf(peer)?.attach?.admission) clearKimiAdmission();
5770
6424
  }
5771
- else if (owner && owner.state !== "offline") {
6425
+ else if (owner && (owner.state !== "offline" || isExternalInstance(peer) && owner.pendingOwnedProcess !== false)) {
5772
6426
  const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
5773
6427
  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
6428
  }
@@ -5956,7 +6610,7 @@ export async function startDaemon(opts: DaemonOptions) {
5956
6610
  if (file !== "config.local.json") return;
5957
6611
  const texts = configSourceTexts(opts.cwd);
5958
6612
  texts["config.local.json"] = text;
5959
- const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
6613
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
5960
6614
  const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
5961
6615
  // Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
5962
6616
  checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
@@ -6057,7 +6711,7 @@ export async function startDaemon(opts: DaemonOptions) {
6057
6711
  case "start_peer": {
6058
6712
  // #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
6713
  if (!peer()) return bad;
6060
- const result = await hubStart(a.peer, "the dashboard");
6714
+ const result = await hubStart(a.peer, "the dashboard", session?.liveTest !== true);
6061
6715
  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
6716
  // A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
6063
6717
  if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
@@ -6082,6 +6736,7 @@ export async function startDaemon(opts: DaemonOptions) {
6082
6736
  }
6083
6737
  }
6084
6738
 
6739
+ const recoverySessionDigest = (id: string | undefined) => id === undefined ? undefined : createHash("sha256").update(id).digest("hex");
6085
6740
  const recoveryView = () => ({
6086
6741
  operationId: recoveryOperationId,
6087
6742
  phase: recoveryPhase,
@@ -6091,26 +6746,33 @@ export async function startDaemon(opts: DaemonOptions) {
6091
6746
  blockers: [
6092
6747
  ...(starting.size ? ["peer startup in progress"] : []),
6093
6748
  ...(!budget.recoverySettled ? ["budget transition in progress"] : []),
6094
- ...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
6749
+ ...([...bus.peers].filter(([id, peer]) => !id.includes("@") && peer.state === "busy").map(([id]) => `${id} is busy`)),
6095
6750
  ...(permissions.size ? ["pending approvals"] : []),
6096
6751
  // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
6097
6752
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
6098
6753
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
6099
6754
  ...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
6755
+ ...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
6756
+ ...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
6100
6757
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
6101
6758
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
6102
6759
  ...(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`)),
6760
+ ...([...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
6761
  ],
6105
6762
  integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
6106
6763
  peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
6107
6764
  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];
6765
+ const owner = bus.peers.get(peer.id), definition = definitionOf(peer.id);
6766
+ const observation = definition?.transport === "claude-channel" && isWsPeer(owner)
6767
+ ? { kind: definition.kind, attached: owner.attached, ...(now?.sessionId === undefined ? {} : { sessionDigest: recoverySessionDigest(now.sessionId) }) } : undefined;
6768
+ const visible = recoveryPhase === "restored" || recoveryPhase === "released" ? now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }
6769
+ : now ? { ...peer, ...now, queueIds: now.queueIds } : peer;
6770
+ return [peer.id, { ...visible, ...(observation === undefined ? {} : { observation }) }];
6110
6771
  })),
6111
6772
  });
6112
6773
  const recoveryError = (error: string) => ({ t: "recovery", ok: false, error });
6113
- async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
6774
+ async function recoveryOp(msg: any, checkCurrent: () => void = () => {}): Promise<Record<string, unknown>> {
6775
+ checkCurrent();
6114
6776
  if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
6115
6777
  if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
6116
6778
  const incompatibleCancellationTarget = () => {
@@ -6119,6 +6781,9 @@ export async function startDaemon(opts: DaemonOptions) {
6119
6781
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
6120
6782
  };
6121
6783
  const incompatibleRecoveryTarget = () => {
6784
+ 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";
6785
+ 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";
6786
+ if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
6122
6787
  const cancelled = incompatibleCancellationTarget();
6123
6788
  if (cancelled) return cancelled;
6124
6789
  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 +6802,9 @@ export async function startDaemon(opts: DaemonOptions) {
6137
6802
  }
6138
6803
 
6139
6804
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
6805
+ 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
6806
  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); }
6807
+ try { await peer.captureResume(); checkCurrent(); } catch (error) { return recoveryError((error as Error).message); }
6142
6808
  if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
6143
6809
  }
6144
6810
  }
@@ -6163,8 +6829,9 @@ export async function startDaemon(opts: DaemonOptions) {
6163
6829
  budget.setRecoveryHold(true);
6164
6830
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
6165
6831
  await bus.fenceRecovery();
6832
+ checkCurrent();
6166
6833
  { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
6167
- const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
6834
+ const blocked = [...bus.peers].filter(([id, peer]) => !id.includes("@") && peer.state === "busy").map(([id]) => id);
6168
6835
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
6169
6836
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
6170
6837
  recoveryPhase = "prepared";
@@ -6184,7 +6851,7 @@ export async function startDaemon(opts: DaemonOptions) {
6184
6851
  recoveryPhase = undefined;
6185
6852
  recoveryPeerSnapshot = undefined;
6186
6853
  clearTimeout(recoveryLeaseTimer);
6187
- budget.setRecoveryHold(false);
6854
+ budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6188
6855
  writeStatus();
6189
6856
  return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
6190
6857
  }
@@ -6224,11 +6891,12 @@ export async function startDaemon(opts: DaemonOptions) {
6224
6891
  }
6225
6892
  if (msg.op === "release") {
6226
6893
  if (recoveryPhase === "released") {
6227
- bus.setRecoveryHold(false); budget.setRecoveryHold(false);
6894
+ bus.setRecoveryHold(false); budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6228
6895
  return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
6229
6896
  }
6230
6897
  if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
6231
6898
  await bus.fenceRecovery();
6899
+ checkCurrent();
6232
6900
  const current = recoveryPeers();
6233
6901
  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
6902
  if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
@@ -6240,7 +6908,7 @@ export async function startDaemon(opts: DaemonOptions) {
6240
6908
  recoveryPhase = "released";
6241
6909
  clearTimeout(recoveryLeaseTimer);
6242
6910
  bus.setRecoveryHold(false);
6243
- budget.setRecoveryHold(false);
6911
+ budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
6244
6912
  writeStatus();
6245
6913
  return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
6246
6914
  }
@@ -6339,8 +7007,10 @@ export async function startDaemon(opts: DaemonOptions) {
6339
7007
  try { return await action(); } finally { permissionChanges.delete(peer); release(); }
6340
7008
  }
6341
7009
 
6342
- async function reconcileNativeStart(peer: string, owner: AcpPeer | CodexPeer): Promise<void> {
7010
+ async function reconcileNativeStart(peer: string, owner: AcpPeer | CodexPeer, startCurrent?: () => void): Promise<void> {
7011
+ startCurrent?.();
6343
7012
  const result = await permissionChange(peer, async () => {
7013
+ startCurrent?.();
6344
7014
  if (stopping) return { ok: false, error: "hub is stopping" };
6345
7015
  const selected = permissionMode(peer);
6346
7016
  const proxy = frontendProxyState(peer, owner);
@@ -6349,8 +7019,22 @@ export async function startDaemon(opts: DaemonOptions) {
6349
7019
  if (!readOnlyPeer(peer) && owner.permissionModeState !== "unmanaged" && owner.getPermissionMode() !== selected) {
6350
7020
  if (proxy.port && !proxy.port.attached) proxy.port.setStartupPermissionMode(selected);
6351
7021
  else await owner.setPermissionMode(selected);
7022
+ startCurrent?.();
7023
+ }
7024
+ if (isExternalInstance(peer)) {
7025
+ const launch = externalLaunches.get(peer), current = captureExternalBinding(peer);
7026
+ if (!membershipAllowed(peer) || !launch || launch.owner !== owner || !await verifyManagedAcpLaunch(owner, launch.options) || !sameExternalInstanceBinding(launch.binding, current)) return { ok: false, error: "external instance admission changed" };
7027
+ startCurrent?.();
7028
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || externalWorkspaceIdentity(current.executionCwd) !== launch.workspace || !sameExternalInstanceBinding(launch.binding, captureExternalBinding(peer))) return { ok: false, error: "external instance admission changed" };
7029
+ }
7030
+ // Roles may change while native startup/mode acknowledgement is awaited; the current workspace policy still owns admission.
7031
+ if (declarations[peer]) {
7032
+ try {
7033
+ if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== executionRoots.get(peer)) return { ok: false, error: "instance execution policy changed during startup" };
7034
+ } catch { return { ok: false, error: "instance execution policy changed during startup" }; }
6352
7035
  }
6353
7036
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
7037
+ startCurrent?.();
6354
7038
  executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
6355
7039
  bus.add(owner);
6356
7040
  // start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
@@ -6365,6 +7049,7 @@ export async function startDaemon(opts: DaemonOptions) {
6365
7049
  let refusalRecorded=false;
6366
7050
  const reply = (body: Record<string, unknown>) => {
6367
7051
  const label=operationalRequestLabel(msg);
7052
+ if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) body = { ok: false, error: "external instance launch or execution root changed" };
6368
7053
  if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
6369
7054
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
6370
7055
  if(body.authorityRefused===true) {
@@ -6377,6 +7062,7 @@ export async function startDaemon(opts: DaemonOptions) {
6377
7062
  };
6378
7063
  if (!c.authed) {
6379
7064
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7065
+ if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
6380
7066
  if (msg.v !== PROTOCOL) {
6381
7067
  // An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
6382
7068
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
@@ -6394,6 +7080,7 @@ export async function startDaemon(opts: DaemonOptions) {
6394
7080
  // 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
7081
  c.peer = String(msg.peer ?? "");
6396
7082
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
7083
+ if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
6397
7084
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
6398
7085
  if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
6399
7086
  const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
@@ -6402,6 +7089,14 @@ export async function startDaemon(opts: DaemonOptions) {
6402
7089
  c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
6403
7090
  if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
6404
7091
  }
7092
+ if (isExternalInstance(c.peer)) {
7093
+ const launch = externalLaunches.get(c.peer), current = currentExecutionOwner(c.peer);
7094
+ 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");
7095
+ // ACP may initialize its MCP server during session/new. Authenticate the pending launch,
7096
+ // but currentExternalTools fences every operation until the verified spawn joins the bus.
7097
+ c.externalOwner = { owner: launch.owner, launchId: launch.launchId, ...(launch.generation ? { generation: launch.generation } : {}) };
7098
+ if (current && !currentExternalTools(c)) return sock.close(4403, "external instance admission changed");
7099
+ }
6405
7100
  // #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
7101
  // 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
7102
  if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
@@ -6410,9 +7105,11 @@ export async function startDaemon(opts: DaemonOptions) {
6410
7105
  }
6411
7106
  } else {
6412
7107
  c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
7108
+ if (isExternalInstance(c.peer)) return sock.close(4403, "external module instances require their managed ACP launch");
6413
7109
  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
7110
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
6415
7111
  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");
7112
+ if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
6416
7113
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
6417
7114
  if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
6418
7115
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
@@ -6434,7 +7131,7 @@ export async function startDaemon(opts: DaemonOptions) {
6434
7131
  revokeTerminalExecution(c.peer);
6435
7132
  const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
6436
7133
  try {
6437
- const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
7134
+ const root = resolveExecutionCwd(c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
6438
7135
  if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
6439
7136
  executionRoots.set(c.peer, root);
6440
7137
  executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
@@ -6453,13 +7150,29 @@ export async function startDaemon(opts: DaemonOptions) {
6453
7150
  }
6454
7151
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
6455
7152
  }
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" });
7153
+ const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
7154
+ && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
7155
+ 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
7156
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
7157
+ if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
6458
7158
  if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
6459
7159
  switch (msg.t) {
7160
+ case "permission_failure_probe": {
7161
+ const launch = currentImplementerTools(c), entry = currentImplementerFailure(c, launch);
7162
+ if (!entry || ++entry.probeCount > NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) return void reply({ ok: false, error: "permission diagnostic unavailable" });
7163
+ // Validation input is temporary. No caller-selected stage, native id or body is retained or echoed.
7164
+ const probe = buildNativePermissionProbe(msg.request);
7165
+ if (!probe || validateNativePermissionRequest(probe.request)) return void reply({ ok: false, error: "permission diagnostic unavailable" });
7166
+ entry.permissionAttempt++;
7167
+ const recorded = entry.trace.record("invalid_permission_request");
7168
+ return void reply(recorded ? { ok: true } : { ok: false, error: "permission diagnostic unavailable" });
7169
+ }
6460
7170
  case "permission_request": {
6461
7171
  const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
6462
7172
  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" });
7173
+ const failure = currentImplementerFailure(c, launch), attempt = failure ? ++failure.permissionAttempt : 0;
7174
+ failure?.trace.permissionObserved();
7175
+ const failureCurrent = () => !!failure && failure.permissionAttempt === attempt && currentImplementerFailure(c) === failure;
6463
7176
  const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
6464
7177
  const pending = new AbortController(), abortPending = () => pending.abort();
6465
7178
  launch.signal.addEventListener("abort", abortPending, { once: true });
@@ -6468,10 +7181,13 @@ export async function startDaemon(opts: DaemonOptions) {
6468
7181
  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
7182
  c.toolsOwner?.owner.keepApprovalAlive?.();
6470
7183
  void (async () => {
6471
- const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current()) : undefined;
7184
+ const updatedInput = msg.wrapperProtocol === 1 ? await automaticImplementerShell(c, msg.tool, msg.input, () => sock.readyState === WebSocket.OPEN && current(), failure ? { entry: failure, attempt } : undefined) : undefined;
6472
7185
  if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
6473
7186
  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);
7187
+ 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, {
7188
+ registered: id => { if (failureCurrent()) failure!.trace.permissionObserved(id); },
7189
+ expired: id => { if (failureCurrent()) failure!.trace.record("manual_queue_expired", id); },
7190
+ });
6475
7191
  const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
6476
7192
  reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
6477
7193
  })().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
@@ -6479,12 +7195,12 @@ export async function startDaemon(opts: DaemonOptions) {
6479
7195
  }
6480
7196
  case "conductor_operational": {
6481
7197
  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"});
7198
+ if(!["authorize","admit","logs","export","orphans"].includes(msg.op))return void reply({ok:false,error:"unknown operational request"});
7199
+ const command=msg.op==="authorize"||msg.op==="admit"?msg.command:msg.op==="orphans"?"doctor orphans kill":msg.op;
7200
+ // #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.
7201
+ if(msg.op==="admit"?!conductorLocalOperation(command):!(CONDUCTOR_DAEMON_OPERATIONS as readonly string[]).includes(command))return void reply({ok:false,error:"unknown operational command"});
6486
7202
  const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
6487
- if(msg.op==="authorize")return void reply({ok:true,instanceId});
7203
+ if(msg.op==="authorize"||msg.op==="admit")return void reply({ok:true,instanceId});
6488
7204
  if(msg.op==="logs" || msg.op==="export") {
6489
7205
  const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
6490
7206
  if(!lines.available)return void reply({ok:false,error:"operational history unavailable"});
@@ -6537,14 +7253,15 @@ export async function startDaemon(opts: DaemonOptions) {
6537
7253
  }
6538
7254
  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
7255
  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" });
7256
+ 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
7257
  return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
6542
7258
  }
6543
7259
  if (msg.op === "discard") {
6544
7260
  // #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
7261
  // 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
7262
  // 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" });
7263
+ 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" });
7264
+ if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
6548
7265
  const state = msg.state === undefined ? "queued" : msg.state;
6549
7266
  if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
6550
7267
  const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
@@ -6558,7 +7275,9 @@ export async function startDaemon(opts: DaemonOptions) {
6558
7275
  const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
6559
7276
  try {
6560
7277
  if (!current || current.state !== state) throw new Error("unknown delivery");
6561
- bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
7278
+ bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
7279
+ if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
7280
+ discarded++;
6562
7281
  }
6563
7282
  catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
6564
7283
  }
@@ -6573,7 +7292,10 @@ export async function startDaemon(opts: DaemonOptions) {
6573
7292
  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
7293
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
6575
7294
  try {
7295
+ const before = bus.queueShow(msg.id);
7296
+ if (before?.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
6576
7297
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
7298
+ if (before?.peer.includes("@")) roomChat.disposition(before.peer, msg.id, msg.action);
6577
7299
  writeStatus();
6578
7300
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id),false,c.role!=="console") });
6579
7301
  } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
@@ -6645,9 +7367,131 @@ export async function startDaemon(opts: DaemonOptions) {
6645
7367
  return void reply({ ok: false, error: "unknown execution budget operation" });
6646
7368
  } catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6647
7369
  }
6648
- case "recovery":
6649
- if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
6650
- void recoveryOp(msg).then(reply, (error) => {
7370
+ case "recovery_restore": {
7371
+ try {
7372
+ const current = admitOperational(sock, "recovery restore"); current();
7373
+ const raw = msg.authority as ConductorRecoveryBinding;
7374
+ if (!raw || raw.peer !== c.peer || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir
7375
+ || !restored || restored.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId
7376
+ || recoveryPhase !== "restored" || recoveryOperationId !== raw.operationId || instanceId === raw.sourceInstanceId
7377
+ || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest) throw new RecoveryAuthorityError();
7378
+ const proof = authorizeConductorRecovery(opts.stateDir, raw, () => { current(); const value = currentRecoveryPolicy(c.peer!); return value.policyDigest === raw.policyDigest && value.boardDigest === raw.boardDigest; });
7379
+ const fence = () => { current(); assertConductorRecovery(proof); if (recoveryPhase !== "restored" || recoveryOperationId !== raw.operationId) throw new RecoveryAuthorityError(); };
7380
+ const saved = restored.peers.find(row => row.id === msg.peer);
7381
+ if (!saved || saved.state === "offline" || saved.launch?.mode === "tui" || definitionOf(saved.id)?.transport === "claude-channel" || !definitionOf(saved.id)?.session.restoreArgs) throw new RecoveryAuthorityError();
7382
+ // Host snapshot only. No argv, model, session or launch field supplied by the tools caller is used.
7383
+ const launch = saved.launch ?? {};
7384
+ const args: Parameters<typeof startPeer>[1] = { mode: "headless",
7385
+ ...(typeof launch.model === "string" ? { model: launch.model } : {}), ...(typeof launch.route === "string" ? { route: launch.route } : {}),
7386
+ ...(launch.backend === "auto" || launch.backend === "dgx" || launch.backend === "mlx" ? { backend: launch.backend } : {}),
7387
+ ...(saved.sessionId ? { sessionId: saved.sessionId } : {}), ...(typeof launch.sessionFile === "string" ? { sessionFile: launch.sessionFile } : {}),
7388
+ ...(typeof launch.providerProfile === "string" ? { providerProfile: launch.providerProfile } : {}) };
7389
+ fence();
7390
+ 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" }));
7391
+ } catch { reply({ ok: false, error: "conductor recovery authority unavailable; the person runs it in ahub console or a terminal" }); }
7392
+ return;
7393
+ }
7394
+ case "recovery_authority": {
7395
+ void (async () => { try {
7396
+ const settlingSource = msg.op === "verify" && recoveryCommitted && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
7397
+ const current = operationalCurrent(sock, false, settlingSource); current();
7398
+ 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 }) }); }
7399
+ if (!["issue", "verify"].includes(msg.op)) throw new RecoveryAuthorityError();
7400
+ const raw = msg.binding as ConductorRecoveryBinding;
7401
+ if (!raw || raw.peer !== c.peer || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir) throw new RecoveryAuthorityError();
7402
+ const project = { root: opts.cwd, id: projectId, stateDir: opts.stateDir };
7403
+ const expected = { ...raw, ...currentRecoveryPolicy(c.peer!) };
7404
+ const fresh = () => { current(); return JSON.stringify(currentRecoveryPolicy(c.peer!)) === JSON.stringify({ policyDigest: raw.policyDigest, boardDigest: raw.boardDigest }); };
7405
+ if (msg.op === "issue") {
7406
+ const plan = msg.plan as UpgradePlan;
7407
+ const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
7408
+ 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))
7409
+ || 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();
7410
+ if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
7411
+ if (plan.kind === "upgrade") {
7412
+ // Shared package/plugin effects cover every running or uncertain registered project, not a caller-selected subset.
7413
+ const affected = registeredProjects().filter(project => {
7414
+ const control = readControl(project.stateDir);
7415
+ return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
7416
+ });
7417
+ 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();
7418
+ }
7419
+ const selected = plan.projects.find(row => row.project.id === projectId)!;
7420
+ const reconnect = new Map<string, import("../cli/upgrade.ts").RecoveryPeer["observation"]>();
7421
+ const reconnectCurrent: Array<() => boolean> = [];
7422
+ if (selected.reconnectOnly !== undefined) {
7423
+ if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
7424
+ for (const id of selected.reconnectOnly) {
7425
+ const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
7426
+ if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
7427
+ || Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
7428
+ || !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
7429
+ || row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
7430
+ const generation = owner.sessionGeneration;
7431
+ const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
7432
+ if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)) throw new RecoveryAuthorityError();
7433
+ reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
7434
+ && definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
7435
+ reconnect.set(id, row.observation);
7436
+ }
7437
+ }
7438
+ if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
7439
+ 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> }) => {
7440
+ const launch = row.args ?? row.launch ?? {};
7441
+ const observed = reconnect.get(row.id);
7442
+ return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
7443
+ ...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
7444
+ ...(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) } : {}),
7445
+ sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
7446
+ 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))) };
7447
+ };
7448
+ const ordered = (rows: Parameters<typeof normalize>[0][]) => rows.map(normalize).sort((a, b) => a.id.localeCompare(b.id));
7449
+ if (JSON.stringify(ordered(selected.source.peers)) !== JSON.stringify(ordered(Object.values(recoveryPeers())))) throw new RecoveryAuthorityError();
7450
+ exactVersion(plan.version);
7451
+ if (verifyRecoveryTarget(raw.kind, raw.targetRoot, plan.version) !== raw.targetDigest) throw new RecoveryAuthorityError();
7452
+ readRecoveryTargetFeatures(raw.targetRoot);
7453
+ if (raw.kind === "restart") {
7454
+ if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
7455
+ } else {
7456
+ const staged = join(hubHome(), "releases", plan.version);
7457
+ if (raw.targetRoot !== join(staged, "node_modules/@staix/agent-hub")) throw new RecoveryAuthorityError();
7458
+ const verified = JSON.parse(readFileSync(join(staged, "verified.json"), "utf8"));
7459
+ if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
7460
+ }
7461
+ if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
7462
+ current();
7463
+ if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false })) throw new RecoveryAuthorityError();
7464
+ current();
7465
+ issueConductorRecoveryAuthority(opts.stateDir, expected, fresh);
7466
+ } else {
7467
+ if (instanceId !== raw.sourceInstanceId && (restored?.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest)) throw new RecoveryAuthorityError();
7468
+ assertConductorRecovery(authorizeConductorRecovery(opts.stateDir, expected, fresh));
7469
+ }
7470
+ current(); return void reply({ ok: true, instanceId, target: restored?.operationId === raw.operationId && instanceId !== raw.sourceInstanceId, operationId: recoveryOperationId, phase: recoveryPhase });
7471
+ } catch { return void reply({ ok: false, error: "conductor recovery authority unavailable; the person runs it in ahub console or a terminal" }); }
7472
+ })().catch(() => reply({ ok: false, error: "conductor recovery authority unavailable" }));
7473
+ return;
7474
+ }
7475
+ case "recovery": {
7476
+ let recoveryMessage = msg;
7477
+ let recoveryCurrent: () => void = () => {};
7478
+ if (c.role !== "console") {
7479
+ try {
7480
+ if (!["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) throw new RecoveryAuthorityError();
7481
+ const current = msg.op === "inspect" ? operationalCurrent(sock) : admitOperational(sock, `recovery ${msg.op}`); current();
7482
+ if (msg.op !== "inspect" || msg.operationId !== undefined) {
7483
+ const raw = msg.authority as ConductorRecoveryBinding;
7484
+ if (!raw || raw.peer !== c.peer || raw.operationId !== msg.operationId || raw.projectRoot !== opts.cwd || raw.projectId !== projectId || raw.stateDir !== opts.stateDir) throw new RecoveryAuthorityError();
7485
+ if (instanceId !== raw.sourceInstanceId && (restored?.operationId !== raw.operationId || restored.sourceInstanceId !== raw.sourceInstanceId || recoveryTargetDigest(raw.kind, resolve(import.meta.dir, "../..")) !== raw.targetDigest)) throw new RecoveryAuthorityError();
7486
+ const proof = authorizeConductorRecovery(opts.stateDir, raw, () => { current(); const value = currentRecoveryPolicy(c.peer!); return value.policyDigest === raw.policyDigest && value.boardDigest === raw.boardDigest; });
7487
+ recoveryCurrent = () => { current(); assertConductorRecovery(proof); if (recoveryTargetDigest(raw.kind, raw.targetRoot) !== raw.targetDigest) throw new RecoveryAuthorityError(); };
7488
+ recoveryCurrent();
7489
+ recoveryMessage = { ...msg, targetFeatures: readRecoveryTargetFeatures(raw.targetRoot) };
7490
+ } else recoveryCurrent = current;
7491
+ recoveryCurrent();
7492
+ } catch { return void reply(recoveryError("conductor recovery authority unavailable; the person runs it in ahub console or a terminal")); }
7493
+ }
7494
+ void recoveryOp(recoveryMessage, recoveryCurrent).then(reply, (error) => {
6651
7495
  if (isDatabaseBusy(error)) {
6652
7496
  try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
6653
7497
  const busy = new RecoveryDatabaseBusy();
@@ -6656,17 +7500,24 @@ export async function startDaemon(opts: DaemonOptions) {
6656
7500
  else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
6657
7501
  });
6658
7502
  return;
7503
+ }
6659
7504
  case "send": {
6660
7505
  if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
6661
7506
  // A human at the console should not wait out the batch window; agents default to status.
6662
7507
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
6663
7508
  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;
7509
+ const recipients = typeof msg.to === "string" && msg.to.length ? [msg.to] : msg.to;
7510
+ if (Object.hasOwn(msg, "to") && (!Array.isArray(recipients) || recipients.some(id => typeof id !== "string" || !id.length))) {
7511
+ return void reply({ t: "sent", ok: false, error: "to must be an array of peer ids" });
7512
+ }
7513
+ const to: PeerId[] | undefined = Array.isArray(recipients) && recipients.length ? [...new Set<string>(recipients)] : undefined;
6665
7514
  const unknown = to?.filter((id) => !bus.knownPeers().includes(id) && membershipAllowed(id)) ?? [];
6666
7515
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
6667
7516
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
6668
7517
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
6669
7518
  let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
7519
+ try { bus.checkRemotePublication(env); }
7520
+ 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
7521
  const offRoster = bus.offRosterRecipients(env);
6671
7522
  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
7523
  const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
@@ -6676,7 +7527,10 @@ export async function startDaemon(opts: DaemonOptions) {
6676
7527
  const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
6677
7528
  const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
6678
7529
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
6679
- const { targets, dropped } = bus.publishResult(env);
7530
+ let published: ReturnType<Bus["publishResult"]>;
7531
+ try { published = bus.publishResult(env); }
7532
+ 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" }); }
7533
+ const { targets, dropped } = published;
6680
7534
  if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
6681
7535
  if (dropped === "hop") return void reply({ t: "sent", ok: true, targets, dropped, recorded: priority === "fyi",
6682
7536
  notice: `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to` });
@@ -6789,13 +7643,13 @@ export async function startDaemon(opts: DaemonOptions) {
6789
7643
  const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
6790
7644
  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
7645
  }
6792
- let nativeClaude = false;
7646
+ let nativeClaude = false, nativeTranscript: string | undefined;
6793
7647
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
6794
7648
  const expectedLaunch = claudeLaunchProof.expectedLaunch(peer);
6795
7649
  const current = claudeSession(peer);
6796
7650
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
6797
7651
  if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
6798
- const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
7652
+ const transcript = nativeTranscript = claudeTranscript(sessionId, msg.transcriptPath, peer);
6799
7653
  const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
6800
7654
  const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
6801
7655
  writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
@@ -6805,6 +7659,9 @@ export async function startDaemon(opts: DaemonOptions) {
6805
7659
  if (!nativeClaude) return void reply({ t: "facts", ok: false });
6806
7660
  }
6807
7661
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
7662
+ if (nativeClaude && (phase === "pre" || phase === "stop")) {
7663
+ try { observeClaudeDoing(peer, sessionId!, nativeTranscript, phase === "pre" ? tool : "", input); } catch { /* #878: a display value never costs the hook its answer */ }
7664
+ }
6808
7665
  const nativePeer = nativeClaude ? claudeLaunchProof.owner(peer) : undefined;
6809
7666
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudeLaunchProof.permissionLaunch(peer) : undefined;
6810
7667
  const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
@@ -6814,7 +7671,10 @@ export async function startDaemon(opts: DaemonOptions) {
6814
7671
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
6815
7672
  // 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
7673
  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 } : {}) } : {};
7674
+ const plannerOnly = !!declarations[peer] && definitionOf(peer)?.transport === "claude-channel" && isPlannerOnly(config.roles[peer] ?? []);
7675
+ // Older hooks do not know mutationGrants; the existing permission field must also withhold their mutation grant.
7676
+ const plannerMutation = plannerOnly && !["Read", "LS", "Glob", "Grep"].includes(tool);
7677
+ 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
7678
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
6819
7679
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
6820
7680
  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 +7884,7 @@ export async function startDaemon(opts: DaemonOptions) {
7024
7884
  if (tasks.isPii(now)) return { id: now.id, title: tasks.publicTitle(now), private: true };
7025
7885
  if (task.history.length !== now.history.length || !statuses.has(task.id)) throw new Error("task changed");
7026
7886
  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])]));
7887
+ 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
7888
  return { id: now.id, title: tasks.publicTitle(now), receipt: publicReadValue(tasks.publicView(now).sdlc), ...safeStatus };
7029
7889
  });
7030
7890
  current(); reply({ ok: true, public: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
@@ -7086,8 +7946,70 @@ export async function startDaemon(opts: DaemonOptions) {
7086
7946
  })().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
7087
7947
  return;
7088
7948
  }
7949
+ case "room": {
7950
+ const client = sock.data;
7951
+ const personRoom = c.role === "console";
7952
+ const roomCurrent = () => !stopping && !recoveryActive() && !bus.isRecoveryHeld
7953
+ && sock.data === client && sock.readyState === WebSocket.OPEN && personRoom;
7954
+ const roomFailure = () => reply({ ok: false, error: "room operation refused; check room state, authority and arguments" });
7955
+ if (msg.op === "list" || msg.op === "status") {
7956
+ if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
7957
+ || Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
7958
+ void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }), roomFailure);
7959
+ return;
7960
+ }
7961
+ if (["inbox_list", "inbox_show", "inbox_done", "inbox_discard"].includes(msg.op)) {
7962
+ if (!personRoom) return void reply({ ok: false, error: "room inbox is person-only" });
7963
+ if (!roomCurrent() || Object.keys(msg).some(key => !["t", "op", "input", "rid"].includes(key))) return roomFailure();
7964
+ const input = msg.input;
7965
+ if (!input || typeof input !== "object" || Array.isArray(input)) return roomFailure();
7966
+ if (msg.op === "inbox_list") {
7967
+ if (Object.keys(input).some(key => key !== "name") || (input.name !== undefined && typeof input.name !== "string")) return roomFailure();
7968
+ void roomCommands.inboxRows(input.name, roomCurrent).then(inbox => {
7969
+ if (!roomCurrent()) return roomFailure();
7970
+ reply({ ok: true, inbox });
7971
+ }, error => {
7972
+ if (!roomCurrent()) return roomFailure();
7973
+ if (error instanceof RoomStoreError && error.code === "inbox_list_limit") return reply({ ok: false, code: "inbox_list_limit" });
7974
+ roomFailure();
7975
+ });
7976
+ return;
7977
+ }
7978
+ if (Object.keys(input).length !== 1 || typeof input.id !== "string" || !Object.hasOwn(input, "id")) return roomFailure();
7979
+ const operation = msg.op === "inbox_show" ? roomCommands.inboxShow(input.id, roomCurrent)
7980
+ : roomCommands.inboxDispose(input.id, msg.op === "inbox_done" ? "done" : "discard", roomCurrent);
7981
+ void operation.then(item => {
7982
+ if (!roomCurrent()) return roomFailure();
7983
+ if (!item) return reply({ ok: false, code: "inbox_not_found" });
7984
+ reply({ ok: true, item });
7985
+ if (msg.op !== "inbox_show") roomCommands.retryInboxPending();
7986
+ }, roomFailure);
7987
+ return;
7988
+ }
7989
+ if (!personRoom) return void reply({ ok: false, error: "room mutations are person-only" });
7990
+ if (!roomCurrent()) return roomFailure();
7991
+ if (msg.op === "preview") {
7992
+ roomTickets.delete(sock);
7993
+ void roomCommands.preview(msg.action, msg.input, roomCurrent).then(preview => {
7994
+ if (!roomCurrent()) return roomFailure();
7995
+ const ticket = crypto.randomUUID();
7996
+ roomTickets.set(sock, { id: ticket, expires: Date.now() + 30_000, preview });
7997
+ reply({ ok: true, owner: preview.owner, member: preview.member, ticket });
7998
+ }, roomFailure);
7999
+ return;
8000
+ }
8001
+ if (msg.op === "execute") {
8002
+ const ticket = roomTickets.get(sock); roomTickets.delete(sock);
8003
+ if (!ticket || msg.ticket !== ticket.id || Date.now() >= ticket.expires
8004
+ || typeof msg.confirm !== "string" || !/^[0-9a-f]{16,64}$/.test(msg.confirm) || !ticket.preview.member.startsWith(msg.confirm)
8005
+ || Object.keys(msg).some(key => !["t", "op", "ticket", "confirm", "rid"].includes(key))) return roomFailure();
8006
+ void ticket.preview.commit().then(result => reply({ ok: true, ...result }), roomFailure);
8007
+ return;
8008
+ }
8009
+ return void reply({ ok: false, error: "unknown room operation" });
8010
+ }
7089
8011
  case "status":
7090
- return void reply({ t: "status", status: status(c.role === "console") });
8012
+ return void reply({ t: "status", status: status(c.role === "console", false, true) });
7091
8013
  case "cli_audit_ack": {
7092
8014
  if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
7093
8015
  if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
@@ -7108,9 +8030,10 @@ export async function startDaemon(opts: DaemonOptions) {
7108
8030
  void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
7109
8031
  return;
7110
8032
  case "task_catalog_role":
7111
- if (c.role !== "console") return void reply({ ok: false, error: "task roles are a person command" });
8033
+ // #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.
8034
+ 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
8035
  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"}` }); }
8036
+ 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
8037
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
7115
8038
  case "settings_get":
7116
8039
  if (c.role !== "console") {
@@ -7173,7 +8096,7 @@ export async function startDaemon(opts: DaemonOptions) {
7173
8096
  case "peer_start":
7174
8097
  // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
7175
8098
  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" }); });
8099
+ 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
8100
  return;
7178
8101
  case "peer_stop":
7179
8102
  let stopCurrent:(()=>void)|undefined;
@@ -7192,13 +8115,34 @@ export async function startDaemon(opts: DaemonOptions) {
7192
8115
  });
7193
8116
  return;
7194
8117
  case "task":
8118
+ if (c.role === "tools" && msg.op === "task_unassign") {
8119
+ if (conductorPeer(config.roles) !== c.peer) return void reply({ t: "task", ok: false, error: "task_unassign is a console command" });
8120
+ try {
8121
+ const current = admitOperational(sock, "task unassign");
8122
+ void childToolAuthority.run({ by: c.peer!, current: () => { current(); return true; } },
8123
+ () => taskOp(c.peer!, "task_unassign", msg.args ?? {})).then(
8124
+ text => reply({ t: "task", ok: true, text }),
8125
+ error => reply({ t: "task", ok: false, error: error instanceof Error ? error.message : "unassign refused" }));
8126
+ } catch (error) { reply({ t: "task", ok: false, error: error instanceof Error ? error.message : "unassign refused" }); }
8127
+ return;
8128
+ }
7195
8129
  if (c.role === "tools" && msg.op === "release_show") {
7196
8130
  try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
7197
8131
  catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
7198
8132
  }
7199
8133
  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
8134
  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(
8135
+ if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
8136
+ // #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
8137
+ let current: () => void;
8138
+ try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
8139
+ void operationalContext.run({ actor: c.peer, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})).then(
8140
+ (text) => reply({ t: "task", ok: true, text }),
8141
+ (e: Error) => reply({ t: "task", ...operationalFailure(e) }),
8142
+ );
8143
+ return;
8144
+ }
8145
+ (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
8146
  (text) => reply({ t: "task", ok: true, text }),
7203
8147
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
7204
8148
  );
@@ -7211,23 +8155,41 @@ export async function startDaemon(opts: DaemonOptions) {
7211
8155
  return;
7212
8156
  }
7213
8157
  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" });
8158
+ // #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.
8159
+ if (c.role !== "console" && c.role !== "tools") return void reply({ t: msg.t, ok: false, error: "goal_link is a console command" });
7216
8160
  try {
7217
- const text = tasks.linkGoal(USER, msg.task, msg.goal);
8161
+ if (c.role === "tools") admitOperational(sock, "goal link");
8162
+ const text = tasks.linkGoal(c.role === "console" ? USER : c.peer!, msg.task, msg.goal);
7218
8163
  const linked = board.get(Number(msg.task));
7219
8164
  if (linked?.state === "in_progress" && !tasks.isPii(linked)) renewLeases(linked); // already being worked on: hold it now
7220
8165
  return void reply({ t: msg.t, ok: true, text });
7221
8166
  }
7222
- catch (e) { return void reply({ t: msg.t, ok: false, error: (e as Error).message }); }
7223
- case "ask":
8167
+ catch (e) { return void reply({ t: msg.t, ...operationalFailure(e) }); }
8168
+ case "ask": {
7224
8169
  // Console only: the evidence may hold PII task text (on campus), and the answer is for the person at the terminal.
7225
8170
  if (c.role !== "console") return void reply({ t: "ask", ok: false, error: "ask is a console command" });
8171
+ const client = sock.data, revision = configRevision, policy = corpusPolicyVersion();
8172
+ const callerPrivate = () => !!client.peer && contextPii(client.peer);
8173
+ const initialPrivate = callerPrivate();
8174
+ const isPiiText = (text: string) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: text, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii");
8175
+ let evidencePrivacy: readonly TaskEvidencePrivacy[] = [];
8176
+ const askCurrent = () => !stopping && sock.data === client && sock.readyState === WebSocket.OPEN
8177
+ && configRevision === revision && corpusPolicyVersion() === policy && callerPrivate() === initialPrivate
8178
+ && evidencePrivacy.every(previous => {
8179
+ const latest = board.get(previous.id);
8180
+ if (!latest || latest.created !== previous.created) return false;
8181
+ const privacy = taskEvidencePrivacy(latest, { isPii: task => tasks.isPii(task), isPiiText });
8182
+ return privacy.pii === previous.pii && privacy.notePii === previous.notePii;
8183
+ });
7226
8184
  ask(String(msg.question ?? ""), {
7227
8185
  board,
7228
8186
  isPii: (t) => tasks.isPii(t),
7229
8187
  onCampus,
7230
- isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
8188
+ current: askCurrent,
8189
+ privateContext: callerPrivate,
8190
+ bindTaskEvidence: rows => { evidencePrivacy = rows; },
8191
+ corpus: keys => publicCorpus(keys.join(" "), askCurrent),
8192
+ isPiiText,
7231
8193
  ...(config.memory.enabled ? { memory } : {}),
7232
8194
  // #258: entries that match the question, then the graph neighbourhood of what they are about.
7233
8195
  ...(memoryStore ? { native: (keys: string[]) => {
@@ -7243,6 +8205,7 @@ export async function startDaemon(opts: DaemonOptions) {
7243
8205
  ...(inference ? { inference } : {}),
7244
8206
  })
7245
8207
  .then(async (res) => {
8208
+ if (!askCurrent()) return void reply({ t: "ask", ok: true, found: false, evidence: [], pii: true, note: "evidence context changed" });
7246
8209
  let saved: string | undefined;
7247
8210
  if (msg.remember) {
7248
8211
  if (res.pii) saved = "not saved: PII is involved";
@@ -7263,6 +8226,7 @@ export async function startDaemon(opts: DaemonOptions) {
7263
8226
  })
7264
8227
  .catch((e: Error) => reply({ t: "ask", ok: false, error: e.message }));
7265
8228
  return;
8229
+ }
7266
8230
  case "budget":
7267
8231
  if (c.role !== "console") {
7268
8232
  if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
@@ -7339,6 +8303,8 @@ export async function startDaemon(opts: DaemonOptions) {
7339
8303
  }
7340
8304
  async function stopOnce(): Promise<void> {
7341
8305
  stopping = true;
8306
+ roomCommands.stop();
8307
+ tasks.stopQueuePressureRechecks();
7342
8308
  let implementerShellCleanupFailed = false;
7343
8309
  const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
7344
8310
  terminalLaunches.clear();
@@ -7355,11 +8321,19 @@ export async function startDaemon(opts: DaemonOptions) {
7355
8321
  dashboard?.stop();
7356
8322
  stopConfigWatch();
7357
8323
  clearInterval(sweepTimer);
8324
+ clearInterval(schedulesTimer);
7358
8325
  tasks.stopCiWatchers(); // #785 P1b: no per-head CI watcher outlives the hub
7359
8326
  for (const i of intervals) clearInterval(i);
7360
8327
  for (const pending of checkpointWaits.values()) pending.done(undefined);
7361
8328
  for (const p of permissions.values()) p.done(undefined);
7362
8329
  try {
8330
+ // 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
8331
+ // its own timeout) before the peers stop, the board closes and the store closes, so no pass runs against a closed database.
8332
+ if (schedulesPass || manualSchedulePasses.size) {
8333
+ let timer: ReturnType<typeof setTimeout> | undefined;
8334
+ await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
8335
+ clearTimeout(timer);
8336
+ }
7363
8337
  await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
7364
8338
  await terminalLaunches.settled();
7365
8339
  // A peer can still be inside memory recall or its native handshake when kill arrives.
@@ -7377,10 +8351,13 @@ export async function startDaemon(opts: DaemonOptions) {
7377
8351
  await runnerStopped;
7378
8352
  await Promise.all([...modelRelays.values()].map(relay => relay.close()));
7379
8353
  await egress?.close();
8354
+ await webEgress?.close();
7380
8355
  await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
7381
8356
  collectClaudeUsage();
7382
8357
  for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
7383
8358
  budget.close();
8359
+ schedulesStore.close();
8360
+ folderStore.close();
7384
8361
  conductorHolds.close();
7385
8362
  executionBudget.close();
7386
8363
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
@@ -7424,7 +8401,8 @@ export async function startDaemon(opts: DaemonOptions) {
7424
8401
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
7425
8402
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
7426
8403
  ready = true;
7427
- for (const name of CONFIG_SOURCE_FILES) {
8404
+ tasks.scheduleQueuePressureRecheck();
8405
+ for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
7428
8406
  const path = join(opts.cwd, ".agenthub", name);
7429
8407
  const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
7430
8408
  if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
@@ -7465,7 +8443,8 @@ export async function startDaemon(opts: DaemonOptions) {
7465
8443
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
7466
8444
  // #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
7467
8445
  void sweepAgentDrift();
7468
- return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
8446
+ // #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.
8447
+ 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
8448
  } finally {
7470
8449
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
7471
8450
  }