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