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