@staix/agent-hub 0.19.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- 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 +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- 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 +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -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 +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- 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 +8 -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 +1228 -242
- 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 +46 -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 +7 -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/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- 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,14 +16,16 @@ 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";
|
|
11
|
-
import {
|
|
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";
|
|
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";
|
|
14
24
|
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";
|
|
@@ -140,7 +164,8 @@ import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
|
140
164
|
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
|
-
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
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 };
|
|
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 },
|
|
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,11 +578,15 @@ 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;
|
|
540
586
|
/** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
|
|
541
587
|
snapshotClock?: () => number;
|
|
588
|
+
/** Host-only bounds of the per-head CI watcher (#785 P1b); tests shorten them, never read from config or a tool. */
|
|
589
|
+
ciWatch?: { deadlineMs?: number; pollMs?: number };
|
|
542
590
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
543
591
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
544
592
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
@@ -578,8 +626,28 @@ export interface DaemonOptions {
|
|
|
578
626
|
agentDriftEveryMs?: number;
|
|
579
627
|
/** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
|
|
580
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;
|
|
581
638
|
/** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
|
|
582
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"];
|
|
583
651
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
584
652
|
memorySync?: typeof syncMemory;
|
|
585
653
|
/** Host-only test/embedding transports. Never read from config or tool input. */
|
|
@@ -595,6 +663,7 @@ interface Client {
|
|
|
595
663
|
role?: "peer" | "console" | "tools";
|
|
596
664
|
peer?: PeerId;
|
|
597
665
|
launchId?: string;
|
|
666
|
+
externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
|
|
598
667
|
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
599
668
|
tail?: () => void;
|
|
600
669
|
watch?: () => void;
|
|
@@ -626,22 +695,59 @@ export function resolutionRefusal(error: unknown): string {
|
|
|
626
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". */
|
|
627
696
|
export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
|
|
628
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
|
+
|
|
629
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);
|
|
630
710
|
const projectId = opts.projectId ?? randomUUID();
|
|
711
|
+
const sourcePackageDigest = capturedRecoverySourceDigest();
|
|
631
712
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
632
713
|
const startupCleanup: (() => void | Promise<void>)[] = [];
|
|
633
714
|
let ready = false;
|
|
634
715
|
try {
|
|
635
716
|
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
636
|
-
|
|
637
|
-
|
|
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.
|
|
638
743
|
const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
|
|
639
744
|
const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
|
|
640
745
|
const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
|
|
641
746
|
const externalIds = new Set(externalAdapters.map(item => item.kind));
|
|
642
747
|
const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
|
|
643
|
-
const
|
|
644
|
-
|
|
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));
|
|
645
751
|
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
646
752
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
647
753
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
@@ -661,6 +767,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
661
767
|
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
662
768
|
const implementerTranscripts = new Map<string, string>();
|
|
663
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>();
|
|
664
773
|
const verifiedTerminalRootsThisTurn = new Set<object>();
|
|
665
774
|
const reviewerUnavailable = new Map<string, string>();
|
|
666
775
|
const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
|
|
@@ -684,6 +793,66 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
684
793
|
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
685
794
|
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
686
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
|
+
}
|
|
687
856
|
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
|
|
688
857
|
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
|
|
689
858
|
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
@@ -692,8 +861,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
692
861
|
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
693
862
|
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
694
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
|
+
|
|
695
880
|
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
696
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
|
+
});
|
|
697
887
|
/** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
|
|
698
888
|
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => {
|
|
699
889
|
if (!reviewOnlyPeer(peer) || !Array.isArray(view.history)) return view;
|
|
@@ -731,7 +921,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
731
921
|
const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
|
|
732
922
|
const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
|
|
733
923
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
734
|
-
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);
|
|
735
926
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
736
927
|
const mlxConfigProblem = mlxLaunchProblem(config, config.pi) ?? disabledMlxPolicyProblem(config, opts.cwd);
|
|
737
928
|
if (mlxConfigProblem) throw new Error(mlxConfigProblem);
|
|
@@ -745,8 +936,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
745
936
|
let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
|
|
746
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. */}}
|
|
747
938
|
};
|
|
748
|
-
let
|
|
749
|
-
|
|
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);
|
|
750
943
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
751
944
|
const embeddedConfig = structuredClone(config);
|
|
752
945
|
let desiredConfig = structuredClone(config);
|
|
@@ -943,6 +1136,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
943
1136
|
? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
|
|
944
1137
|
: undefined;
|
|
945
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
|
+
}
|
|
946
1149
|
|
|
947
1150
|
// A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
|
|
948
1151
|
const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
|
|
@@ -1028,6 +1231,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1028
1231
|
log(`network: egress proxy on 127.0.0.1:${egress.port}, ${allow.length} allowed host(s) (local.network_allow)`);
|
|
1029
1232
|
}
|
|
1030
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
|
+
}
|
|
1031
1243
|
const admit = (env: Envelope, parent?: string): string | undefined => {
|
|
1032
1244
|
// [FYI] is recorded and costs nobody a turn: nothing to limit.
|
|
1033
1245
|
if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
|
|
@@ -1077,9 +1289,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1077
1289
|
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
1078
1290
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
1079
1291
|
const requestedPeerStops = new Set<string>();
|
|
1080
|
-
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),
|
|
1081
1306
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1082
1307
|
startupCleanup.push(() => bus.closeJournal());
|
|
1308
|
+
const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
|
|
1309
|
+
function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
|
|
1310
|
+
if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
|
|
1311
|
+
return { present: true, port: owner.frontendProxy };
|
|
1312
|
+
}
|
|
1083
1313
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
1084
1314
|
const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
|
|
1085
1315
|
startupCleanup.push(() => conductorHolds.close());
|
|
@@ -1497,7 +1727,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1497
1727
|
const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
|
|
1498
1728
|
const connectionGeneration = (id: string): string | undefined => {
|
|
1499
1729
|
const peer = bus.peers.get(id);
|
|
1500
|
-
if (!peer || peer.state === "offline") return undefined;
|
|
1730
|
+
if (!peer || peer.state === "offline" || isExternalInstance(id) && !currentExecutionOwner(id)) return undefined;
|
|
1501
1731
|
let token = checkConnections.get(peer);
|
|
1502
1732
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1503
1733
|
const hasGeneration = "sessionGeneration" in peer;
|
|
@@ -1513,6 +1743,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1513
1743
|
};
|
|
1514
1744
|
const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
|
|
1515
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
|
+
|
|
1516
1765
|
const publicCheck = (receipt: RunReceipt) => {
|
|
1517
1766
|
const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
|
|
1518
1767
|
return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
|
|
@@ -1611,6 +1860,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1611
1860
|
const tasks: Tasks = new Tasks({
|
|
1612
1861
|
releaseWindow,
|
|
1613
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
|
+
}),
|
|
1614
1869
|
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1615
1870
|
const codes: ("approvals" | "quota")[] = [];
|
|
1616
1871
|
if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
|
|
@@ -1627,6 +1882,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1627
1882
|
const scope = childToolAuthority.getStore();
|
|
1628
1883
|
return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
|
|
1629
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; },
|
|
1630
1887
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1631
1888
|
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1632
1889
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
@@ -1638,6 +1895,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1638
1895
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
1639
1896
|
wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
|
|
1640
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
|
|
1641
1900
|
profile: profileName(config.profile),
|
|
1642
1901
|
profileData: resolvedProfile,
|
|
1643
1902
|
piiPaths: config.pii?.paths,
|
|
@@ -1688,11 +1947,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1688
1947
|
...(config.memory.enabled ? { memory } : {}),
|
|
1689
1948
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1690
1949
|
memoryCheck: checkMemory,
|
|
1691
|
-
|
|
1950
|
+
briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1692
1951
|
memoryStore ? taskMemoryItems : undefined,
|
|
1693
1952
|
(peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
|
|
1694
1953
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
|
|
1695
|
-
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
|
+
}),
|
|
1696
1959
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
1697
1960
|
checkMemory();
|
|
1698
1961
|
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
|
|
@@ -1705,7 +1968,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1705
1968
|
...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
|
|
1706
1969
|
notify,
|
|
1707
1970
|
recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
|
|
1708
|
-
...(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)) } : {}),
|
|
1709
1973
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
1710
1974
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
1711
1975
|
share: (by, line) => {
|
|
@@ -1723,16 +1987,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1723
1987
|
}
|
|
1724
1988
|
},
|
|
1725
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 },
|
|
1726
1993
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
1727
|
-
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 })),
|
|
1728
1995
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
1729
1996
|
quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
|
|
1730
1997
|
review: config.review,
|
|
1731
1998
|
sweep: taskSweepConfig(config.task_sweep),
|
|
1732
1999
|
sweepHeld: () => stopping || recoveryActive(),
|
|
2000
|
+
submodules: () => factsRepo ? submodulePaths(factsRepo) : [], // #798 D1: read from .gitmodules, no git call
|
|
2001
|
+
ciWatch: opts.ciWatch,
|
|
2002
|
+
autoPull: () => config.tasks.auto_pull === true,
|
|
2003
|
+
queueBackpressure: queuePolicy,
|
|
1733
2004
|
cancellationHeld: () => stopping || recoveryActive(),
|
|
1734
2005
|
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1735
|
-
offerPullOnly: (id) => { const peer = bus.peers.get(id); return
|
|
2006
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer?.pullOnly === true; },
|
|
1736
2007
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1737
2008
|
roles: config.roles,
|
|
1738
2009
|
turnFree,
|
|
@@ -2011,12 +2282,47 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2011
2282
|
const p = bus.peers.get(peer);
|
|
2012
2283
|
if (isWsPeer(p) && p.claiming) return undefined;
|
|
2013
2284
|
if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
return undefined;
|
|
2285
|
+
const session = p?.nativeSession;
|
|
2286
|
+
return typeof session === "string" ? session : undefined;
|
|
2017
2287
|
};
|
|
2018
2288
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
2019
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
|
+
};
|
|
2020
2326
|
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
2021
2327
|
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
2022
2328
|
if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
|
|
@@ -2158,11 +2464,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2158
2464
|
};
|
|
2159
2465
|
const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
|
|
2160
2466
|
bus.onDeliver = (peer, originals) => {
|
|
2467
|
+
if (peer.includes("@")) return;
|
|
2161
2468
|
pendingDeliveryTask.delete(peer);
|
|
2162
2469
|
const task = deliveryTask(originals);
|
|
2163
2470
|
if (task !== undefined) pendingDeliveryTask.set(peer, task);
|
|
2164
2471
|
};
|
|
2165
2472
|
bus.onDelivered = (peer, originals) => {
|
|
2473
|
+
if (peer.includes("@")) return;
|
|
2166
2474
|
pendingDeliveryTask.delete(peer);
|
|
2167
2475
|
if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
|
|
2168
2476
|
const turn = turns.get(peer);
|
|
@@ -2193,8 +2501,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2193
2501
|
if (row && tasks.isPii(row)) return;
|
|
2194
2502
|
return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
|
|
2195
2503
|
}) : undefined;
|
|
2196
|
-
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); };
|
|
2197
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;
|
|
2198
2507
|
if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
|
|
2199
2508
|
memoryObservations?.cite(peer, text, id, envs);
|
|
2200
2509
|
};
|
|
@@ -2245,9 +2554,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2245
2554
|
executionOwners.delete(peer); executionRoots.delete(peer);
|
|
2246
2555
|
worktreeFacts.delete(peer); worktreeProjections.delete(peer);
|
|
2247
2556
|
}
|
|
2248
|
-
function currentExecutionOwner(peer: string) {
|
|
2557
|
+
function currentExecutionOwner(peer: string, fresh = false) {
|
|
2249
2558
|
const bound = executionOwners.get(peer);
|
|
2250
|
-
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) {
|
|
2251
2566
|
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2252
2567
|
if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2253
2568
|
revokeTerminalExecution(peer);
|
|
@@ -2257,7 +2572,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2257
2572
|
// an admission/effect. Reuse only within synchronous code, never across an await.
|
|
2258
2573
|
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2259
2574
|
try {
|
|
2260
|
-
if (
|
|
2575
|
+
if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2261
2576
|
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2262
2577
|
verifiedTerminalRootsThisTurn.add(bound);
|
|
2263
2578
|
queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
@@ -2268,13 +2583,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2268
2583
|
try {
|
|
2269
2584
|
if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
|
|
2270
2585
|
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2271
|
-
if (
|
|
2586
|
+
if (resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2272
2587
|
verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
2273
2588
|
}
|
|
2274
2589
|
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2275
2590
|
}
|
|
2276
2591
|
return bound;
|
|
2277
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
|
+
}
|
|
2278
2603
|
function ownerWriteRoot(peer: string): string | undefined {
|
|
2279
2604
|
if (declarations[peer]) {
|
|
2280
2605
|
const bound = currentExecutionOwner(peer);
|
|
@@ -2282,10 +2607,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2282
2607
|
}
|
|
2283
2608
|
return executionRoot(peer);
|
|
2284
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
|
+
}
|
|
2285
2617
|
function implementerChatTask(peer: string): number | undefined {
|
|
2286
2618
|
const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
|
|
2287
2619
|
return owned.length === 1 ? owned[0]!.id : undefined;
|
|
2288
2620
|
}
|
|
2621
|
+
type ImplementerFailureEntry = { launchId: string; generation: string; signal: AbortSignal; trace: ClaudeFailureTrace; probeCount: number; permissionAttempt: number };
|
|
2622
|
+
const implementerFailures = new WeakMap<PeerAdapter, ImplementerFailureEntry>();
|
|
2289
2623
|
function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
|
|
2290
2624
|
const bound = c.toolsOwner, peer = c.peer;
|
|
2291
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;
|
|
@@ -2303,6 +2637,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2303
2637
|
const binding = c.toolsOwner, launch = currentImplementerTools(c);
|
|
2304
2638
|
return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
|
|
2305
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
|
+
}
|
|
2306
2644
|
const shellProjectRepository = githubOriginRepository(opts.cwd);
|
|
2307
2645
|
const shellNeedsDefault = Object.keys(declarations).some(peer => instanceDefinitionOf(peer, declarations, adapterCatalog)?.start.daemonPort === "claude-implementer");
|
|
2308
2646
|
const shellDefaultBranch = shellNeedsDefault && shellProjectRepository
|
|
@@ -2310,7 +2648,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2310
2648
|
: undefined;
|
|
2311
2649
|
let implementerShellStart: Promise<HostShellWrapper> | undefined;
|
|
2312
2650
|
const implementerShellProfiles = new WeakMap<object, { key: string; sandbox: { profile: string; readOnlyData: boolean } }>();
|
|
2313
|
-
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> {
|
|
2314
2652
|
const peer = c.peer, launch = currentImplementerTools(c), root = peer && ownerWriteRoot(peer), policy = config;
|
|
2315
2653
|
if (!peer || !launch || !root || launch.task === undefined || tool !== "Bash" || permissionMode(peer) !== "ask-when-needed") return;
|
|
2316
2654
|
if (Object.keys(original).some(key => !["command", "description", "timeout"].includes(key)) || typeof original.command !== "string"
|
|
@@ -2379,7 +2717,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2379
2717
|
const argv = readProof?.program === "git" ? hubGitArgv([...proof.argv]).slice(1) : proof.argv;
|
|
2380
2718
|
const command = wrapper.issue({ peer, task: taskId, generation: launch.generation, root, branch: binding.branch, executable: proof.executable, argv,
|
|
2381
2719
|
env: taskProof?.env ?? (git ? Object.fromEntries(Object.entries(hubGitEnv(env, { repositoryIsolation: true })).filter((entry): entry is [string, string] => entry[1] !== undefined)) : env),
|
|
2382
|
-
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 } : {}) });
|
|
2383
2722
|
if (command) log(`permission auto-allowed for ${peer}: bash (host wrapper ${readProof ? "read-only shell" : "task command"})`);
|
|
2384
2723
|
return command && { command: command.command, timeout: Math.min(600_000, Number(input.timeout ?? 120_000) + 2000), description: "Hub-owned task command" };
|
|
2385
2724
|
}
|
|
@@ -2565,6 +2904,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2565
2904
|
bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
|
|
2566
2905
|
} catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
|
|
2567
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),
|
|
2568
2912
|
setInterval(() => {
|
|
2569
2913
|
if (!ready || !memoryBinding) return;
|
|
2570
2914
|
const before = memoryProblem;
|
|
@@ -2605,6 +2949,54 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2605
2949
|
};
|
|
2606
2950
|
rescheduleSweep();
|
|
2607
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));
|
|
2608
3000
|
for (const i of intervals) i.unref?.();
|
|
2609
3001
|
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
2610
3002
|
|
|
@@ -2626,6 +3018,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2626
3018
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
2627
3019
|
const [by, op, input] = args;
|
|
2628
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;
|
|
2629
3022
|
const nativeTurn = turns.get(by);
|
|
2630
3023
|
const effectful = ownerToolEffect(op, input);
|
|
2631
3024
|
if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
|
|
@@ -2639,6 +3032,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2639
3032
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
2640
3033
|
const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
|
|
2641
3034
|
const text = await taskOpBody(...args);
|
|
3035
|
+
if (sourceCurrent && !sourceCurrent()) throw new PreEffectToolRefusal("source read unavailable");
|
|
2642
3036
|
if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2643
3037
|
const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
|
|
2644
3038
|
if (conducted) {
|
|
@@ -2646,13 +3040,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2646
3040
|
const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
|
|
2647
3041
|
auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
|
|
2648
3042
|
}
|
|
2649
|
-
const hint = args[3] ? ownHeldHint(by) : "";
|
|
3043
|
+
const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
|
|
2650
3044
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2651
3045
|
} catch (error) {
|
|
2652
3046
|
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
|
|
2653
3047
|
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2654
3048
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2655
|
-
const hint = args[3] ? ownHeldHint(by) : "";
|
|
3049
|
+
const hint = args[3] && op !== "hub_source_read" ? ownHeldHint(by) : "";
|
|
2656
3050
|
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
2657
3051
|
throw error;
|
|
2658
3052
|
} finally {
|
|
@@ -2660,8 +3054,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2660
3054
|
taskOpsInFlight--;
|
|
2661
3055
|
}
|
|
2662
3056
|
};
|
|
3057
|
+
const deliveryRetryAllowed = (owner: PeerAdapter | undefined): boolean => !owner || !("deliveryOnly" in owner) || owner.deliveryOnly === false;
|
|
2663
3058
|
function ownHeldHint(peer: PeerId): string {
|
|
2664
|
-
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(),
|
|
3059
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
|
|
2665
3060
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
2666
3061
|
}
|
|
2667
3062
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
@@ -2671,6 +3066,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2671
3066
|
if (!id || !PEER_ID.test(id)) throw new PreEffectToolRefusal(`${name} must be a peer id, not ${JSON.stringify(v).slice(0, 60)}`);
|
|
2672
3067
|
return id;
|
|
2673
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);
|
|
2674
3079
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
2675
3080
|
if (BOUNDED_TASK_TOOLS.has(op)) {
|
|
2676
3081
|
const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
|
|
@@ -2711,6 +3116,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2711
3116
|
need("propose", "propose tasks");
|
|
2712
3117
|
if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
|
|
2713
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
|
+
}
|
|
2714
3142
|
if (MEMORY_READ_TOOLS.has(op)) {
|
|
2715
3143
|
validateMemoryRead(op, a);
|
|
2716
3144
|
need("memory_read", "read shared memory");
|
|
@@ -2803,7 +3231,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2803
3231
|
switch (op) {
|
|
2804
3232
|
case "hub_delivery_retry": {
|
|
2805
3233
|
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
|
|
2806
|
-
if (
|
|
3234
|
+
if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
2807
3235
|
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
2808
3236
|
bus.retryDelivery(by, a.delivery_id);
|
|
2809
3237
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
@@ -2849,10 +3277,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2849
3277
|
case "hub_task_propose": {
|
|
2850
3278
|
const t = await tasks.propose(by, a);
|
|
2851
3279
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
2852
|
-
|
|
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");
|
|
2853
3282
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
2854
3283
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
2855
|
-
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");
|
|
2856
3285
|
}
|
|
2857
3286
|
case "hub_task_accept": {
|
|
2858
3287
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
@@ -2861,7 +3290,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2861
3290
|
const silent = tasks.silentFor(t.id);
|
|
2862
3291
|
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
2863
3292
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
2864
|
-
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
|
|
2865
3294
|
}
|
|
2866
3295
|
case "hub_task_cancel": {
|
|
2867
3296
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
@@ -2924,7 +3353,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2924
3353
|
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
2925
3354
|
releaseId = release.id;
|
|
2926
3355
|
}
|
|
2927
|
-
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) } : {}) }; }));
|
|
2928
3357
|
}
|
|
2929
3358
|
}
|
|
2930
3359
|
if (op === "task_show" && by !== USER) {
|
|
@@ -2938,12 +3367,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2938
3367
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2939
3368
|
return tasks.publicExplain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
|
|
2940
3369
|
}
|
|
2941
|
-
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`);
|
|
2942
3388
|
switch (op) {
|
|
2943
3389
|
case "release_new":
|
|
2944
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 }) })));
|
|
2945
3393
|
case "github_import":
|
|
2946
|
-
return JSON.stringify(await tasks.importGitHub(
|
|
3394
|
+
return JSON.stringify(await tasks.importGitHub(by, { repo: a.repo, label: a.label, milestone: a.milestone }));
|
|
2947
3395
|
case "release_show": {
|
|
2948
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);
|
|
2949
3397
|
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
@@ -2959,6 +3407,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2959
3407
|
case "backlog_pull":
|
|
2960
3408
|
case "backlog_return":
|
|
2961
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)));
|
|
2962
3435
|
case "review_brief": {
|
|
2963
3436
|
const task = board.get(Number(a.id));
|
|
2964
3437
|
return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
|
|
@@ -2967,12 +3440,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2967
3440
|
const task = board.get(Number(a.id));
|
|
2968
3441
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2969
3442
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2970
|
-
const
|
|
3443
|
+
const publicTask = tasks.publicView(task), codeSymbols = publicTask.codeSymbols;
|
|
2971
3444
|
const consoleDeliverables = await tasks.deliverableStatus(task);
|
|
2972
3445
|
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
2973
3446
|
const fresh = board.get(task.id);
|
|
2974
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" };
|
|
2975
|
-
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);
|
|
2976
3449
|
}
|
|
2977
3450
|
case "deliverables": {
|
|
2978
3451
|
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
@@ -3008,12 +3481,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3008
3481
|
if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
3009
3482
|
if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
3010
3483
|
const codex = bus.peers.get(turn.peer);
|
|
3011
|
-
if (!
|
|
3484
|
+
if (!codex?.revertTurn) throw new Error("codex is not attached");
|
|
3012
3485
|
const workspace = turnLog!.workspace(turn.id);
|
|
3013
3486
|
if (workspace !== undefined) {
|
|
3014
3487
|
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
|
|
3015
3488
|
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
3016
|
-
const validated =
|
|
3489
|
+
const validated = resolveExecutionCwd(turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
3017
3490
|
const bound = executionOwners.get(turn.peer);
|
|
3018
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");
|
|
3019
3492
|
}
|
|
@@ -3021,7 +3494,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3021
3494
|
const held = bus.stateOf(turn.peer) !== "paused";
|
|
3022
3495
|
if (held) bus.pause(turn.peer);
|
|
3023
3496
|
try {
|
|
3024
|
-
await codex.
|
|
3497
|
+
await codex.revertTurn(turn.native);
|
|
3025
3498
|
} finally {
|
|
3026
3499
|
if (held && !manualPaused.has(turn.peer) && !budget.record(turn.peer) && !conductorHolds.has(turn.peer)) bus.resume(turn.peer);
|
|
3027
3500
|
}
|
|
@@ -3077,11 +3550,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3077
3550
|
// adapter with the gate, and only while its native mode is `ask`: Kimi's `yolo` and `auto` modes send no request for a call they allow.
|
|
3078
3551
|
const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
|
|
3079
3552
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
3080
|
-
const launch =
|
|
3553
|
+
const launch = claudeLaunchProof.permissionLaunch(peer), attached = claudeLaunchProof.owner(peer);
|
|
3081
3554
|
// The launch record outlives the process, so it proves nothing alone: the attached session must be the generation whose hook last spoke to the hub.
|
|
3082
3555
|
if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
|
|
3083
3556
|
if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
|
|
3084
|
-
if (!
|
|
3557
|
+
if (!attached || attached.state === "offline" || !claudeLaunchProof.verified(peer))
|
|
3085
3558
|
return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
|
|
3086
3559
|
return { enforced: true };
|
|
3087
3560
|
}
|
|
@@ -3104,9 +3577,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3104
3577
|
const unattendedPeers = new Set<string>();
|
|
3105
3578
|
const startingUnattended = new Map<string, boolean>();
|
|
3106
3579
|
const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
|
|
3107
|
-
const claudePermissionGenerations = new Map<string, string>();
|
|
3108
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 }>();
|
|
3109
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
|
+
|
|
3110
3609
|
const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
|
|
3111
3610
|
docs: opts.releaseDocEvidence,
|
|
3112
3611
|
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
@@ -3144,7 +3643,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3144
3643
|
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
3145
3644
|
*/
|
|
3146
3645
|
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
3147
|
-
if (
|
|
3646
|
+
if (definitionOf(id)?.transport !== "acp" || peer?.steer || bus.stateOf(id) !== "busy") return {};
|
|
3148
3647
|
const since = bus.oldestQueuedImportantAt(id);
|
|
3149
3648
|
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
3150
3649
|
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
@@ -3157,8 +3656,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3157
3656
|
};
|
|
3158
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
|
|
3159
3658
|
const recoveryReady = () => {
|
|
3160
|
-
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" || (
|
|
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;
|
|
3161
3660
|
if (checkPending.size !== 0) return false;
|
|
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
|
|
3162
3664
|
if (!recoveryPeerSnapshot) return true;
|
|
3163
3665
|
const current = recoveryPeers();
|
|
3164
3666
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -3279,7 +3781,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3279
3781
|
} };
|
|
3280
3782
|
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
3281
3783
|
claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
|
|
3282
|
-
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");
|
|
3283
3792
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
3284
3793
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
3285
3794
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
@@ -3301,7 +3810,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3301
3810
|
return row;
|
|
3302
3811
|
});
|
|
3303
3812
|
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
3304
|
-
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])) } : {}) };
|
|
3305
3814
|
};
|
|
3306
3815
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
3307
3816
|
const integrityAs = (expected: unknown) => {
|
|
@@ -3324,9 +3833,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3324
3833
|
};
|
|
3325
3834
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
3326
3835
|
if (readOnlyPeer(id)) return "read-only";
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3836
|
+
const channelOwner = claudeLaunchProof.owner(id);
|
|
3837
|
+
if (channelOwner && channelOwner === peer && channelOwner.state !== "offline") {
|
|
3838
|
+
const launch = claudeLaunchProof.permissionLaunch(id);
|
|
3839
|
+
if (!launch || !claudeLaunchProof.verified(id)) return "unverified";
|
|
3330
3840
|
}
|
|
3331
3841
|
const native = peer?.permissionModeState;
|
|
3332
3842
|
// An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
|
|
@@ -3335,7 +3845,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3335
3845
|
};
|
|
3336
3846
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
3337
3847
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
3338
|
-
if (note &&
|
|
3848
|
+
if (note && peer?.pullOnly === true) return { permissionNote: note };
|
|
3339
3849
|
const mode = permissionDisplay(id, peer);
|
|
3340
3850
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
3341
3851
|
};
|
|
@@ -3348,11 +3858,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3348
3858
|
};
|
|
3349
3859
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
3350
3860
|
const reservations = board.statusReservations();
|
|
3351
|
-
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
3861
|
+
const ids = new Set([...bus.knownPeers(), ...extraIds, ...allExternalInstanceIds(), ...reservations].filter(id => !id.includes("@")));
|
|
3352
3862
|
for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
|
|
3353
3863
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
3354
3864
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
3355
|
-
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)));
|
|
3356
3866
|
};
|
|
3357
3867
|
let checkoutInTick: { branch: ReturnType<typeof mainCheckoutBranch> } | undefined;
|
|
3358
3868
|
const checkoutBranch = () => {
|
|
@@ -3362,20 +3872,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3362
3872
|
}
|
|
3363
3873
|
return checkoutInTick.branch;
|
|
3364
3874
|
};
|
|
3365
|
-
const status = (includeAttentionItems = false, includeAttentionInput = false) => {
|
|
3875
|
+
const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
|
|
3366
3876
|
const taskNeeds = board.statusTasks();
|
|
3367
|
-
const
|
|
3877
|
+
const ownerIsolation = ownerIsolationProjection();
|
|
3878
|
+
const taskStalls = tasks.stalled({ ownerIsolation });
|
|
3368
3879
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
3369
3880
|
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
3881
|
+
const queueBackpressure = tasks.queuePressureStatus(peerIds);
|
|
3370
3882
|
const view = {
|
|
3883
|
+
...(queueBackpressure ? { queueBackpressure } : {}),
|
|
3371
3884
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
3372
3885
|
projectId,
|
|
3373
3886
|
instanceId,
|
|
3887
|
+
rooms: roomCommands.statusView(),
|
|
3374
3888
|
version: VERSION,
|
|
3375
3889
|
pluginBundle: SERVER_JS,
|
|
3376
3890
|
pendingRestart: allPendingRestart(),
|
|
3377
3891
|
retainedConfig,
|
|
3378
3892
|
...(agentDrift.length ? { agentDrift } : {}),
|
|
3893
|
+
...(config.schedules.length ? { schedules: scheduleViews(config.schedules, schedulesStore.rows(), scheduleNow(), tasks.openScheduled(), tasks.schedulesHeld() || opts.schedulesHeld?.() === true) } : {}),
|
|
3379
3894
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
3380
3895
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
3381
3896
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
@@ -3389,13 +3904,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3389
3904
|
features: DAEMON_FEATURES,
|
|
3390
3905
|
configuredAdapters: configuredIds,
|
|
3391
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
|
+
}) }) } : {}),
|
|
3392
3915
|
...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
|
|
3393
3916
|
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
3394
3917
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
3395
3918
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
3396
3919
|
activeInstances: extraIds,
|
|
3397
|
-
ownerIsolation
|
|
3398
|
-
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"])),
|
|
3399
3922
|
stopping,
|
|
3400
3923
|
pid: process.pid,
|
|
3401
3924
|
cwd: opts.cwd,
|
|
@@ -3403,12 +3926,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3403
3926
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
3404
3927
|
codexProxyPort: opts.codexProxyPort,
|
|
3405
3928
|
peers: Object.fromEntries(
|
|
3406
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(
|
|
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 ?? [] } : {}) }]; }),
|
|
3407
3930
|
),
|
|
3408
3931
|
taskWip: tasks.wipBreakdown(peerIds),
|
|
3409
3932
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
3410
3933
|
actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
|
|
3411
|
-
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 } : {}) }])),
|
|
3412
3935
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
3413
3936
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
3414
3937
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
@@ -3419,6 +3942,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3419
3942
|
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
3420
3943
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
3421
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 });
|
|
3422
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 }]));
|
|
3423
3948
|
// Lazy legacy peer lists need not contain a persisted owner that is now offline.
|
|
3424
3949
|
for (const task of taskNeeds) if (task.owner && task.owner !== USER && task.owner !== HUB && !Object.hasOwn(peerNeeds, task.owner)) {
|
|
@@ -3488,8 +4013,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3488
4013
|
}
|
|
3489
4014
|
const noticedHolds = new Set<string>();
|
|
3490
4015
|
let reportingQueues = false;
|
|
4016
|
+
let queuePressureStatusQueued = false;
|
|
3491
4017
|
bus.onQueues = () => {
|
|
3492
4018
|
if (stopping || settlingSuperseded || reportingQueues) return;
|
|
4019
|
+
remoteQueueChanged();
|
|
3493
4020
|
reportingQueues = true;
|
|
3494
4021
|
try {
|
|
3495
4022
|
settleSupersededDeliveries();
|
|
@@ -3501,6 +4028,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3501
4028
|
}
|
|
3502
4029
|
writeStatus();
|
|
3503
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
|
+
}
|
|
3504
4043
|
};
|
|
3505
4044
|
// Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
|
|
3506
4045
|
settleSupersededDeliveries();
|
|
@@ -3684,7 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3684
4223
|
if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
|
|
3685
4224
|
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
3686
4225
|
}
|
|
3687
|
-
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) => {
|
|
3688
4227
|
let meta: Record<string, unknown> = {};
|
|
3689
4228
|
try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
|
|
3690
4229
|
return { peer: p.id, meta };
|
|
@@ -3704,6 +4243,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3704
4243
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
3705
4244
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
3706
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; }
|
|
3707
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; }
|
|
3708
4248
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
3709
4249
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
@@ -3740,8 +4280,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3740
4280
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
3741
4281
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
3742
4282
|
if (e.t === "state") {
|
|
4283
|
+
roomCommands.changed();
|
|
3743
4284
|
log(`state ${e.peer} -> ${e.state}`);
|
|
3744
4285
|
event({ type: "state", peer: e.peer, state: e.state });
|
|
4286
|
+
if (ready) tasks.scheduleQueuePressureRecheck();
|
|
4287
|
+
if (e.peer.includes("@")) { if (!stopping) writeStatus(); return; }
|
|
3745
4288
|
// #248: the add-ons it works with, once per attach (names and versions only).
|
|
3746
4289
|
// A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
|
|
3747
4290
|
// still did its work under that attachment.
|
|
@@ -3849,7 +4392,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3849
4392
|
}
|
|
3850
4393
|
}
|
|
3851
4394
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
3852
|
-
|
|
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}`); }
|
|
3853
4400
|
// After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
|
|
3854
4401
|
if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
|
|
3855
4402
|
}
|
|
@@ -3948,7 +4495,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3948
4495
|
const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
|
|
3949
4496
|
type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
|
|
3950
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;
|
|
3951
4500
|
const result = await requestPermission(req, signal);
|
|
4501
|
+
if (external && (currentExecutionOwner(req.peer, true) !== external || !membershipAllowed(req.peer))) return undefined;
|
|
3952
4502
|
return result.outcome === "answered" ? result.optionId : undefined;
|
|
3953
4503
|
}
|
|
3954
4504
|
/** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
|
|
@@ -3973,7 +4523,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3973
4523
|
}
|
|
3974
4524
|
return !gate.denyOnly && picked.optionId === "allow";
|
|
3975
4525
|
}
|
|
3976
|
-
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> {
|
|
3977
4527
|
const requestEpoch = permissionAnswerEpoch(req.peer);
|
|
3978
4528
|
const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
|
|
3979
4529
|
try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
|
|
@@ -4011,6 +4561,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4011
4561
|
signal?.removeEventListener("abort", aborted);
|
|
4012
4562
|
permissions.delete(id); // withdrawal happens before any observer, resolution or late grant
|
|
4013
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. */ } }
|
|
4014
4565
|
const outcome = option ? "answered" : reason;
|
|
4015
4566
|
if (surface) permissionAnswerEpochs.set(req.peer, permissionAnswerEpoch(req.peer) + 1);
|
|
4016
4567
|
observed(surface ? "person" : outcome === "expired" ? "expired" : "aborted");
|
|
@@ -4024,6 +4575,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4024
4575
|
return true;
|
|
4025
4576
|
};
|
|
4026
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. */ }
|
|
4027
4579
|
writeStatus(); // an approval can wait without any subsequent bus event
|
|
4028
4580
|
timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
|
|
4029
4581
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
@@ -4048,8 +4600,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4048
4600
|
replacingPi.add(pi);
|
|
4049
4601
|
try { await pi.stop(); } finally { replacingPi.delete(pi); }
|
|
4050
4602
|
}
|
|
4051
|
-
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" });
|
|
4052
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" });
|
|
4053
4610
|
if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
|
|
4054
4611
|
if (!membershipAllowed(peer)) return Promise.resolve({ ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` });
|
|
4055
4612
|
if (rosterChanging === peer) return Promise.resolve({ ok: false, error: `${peer} roster change is in progress` });
|
|
@@ -4062,11 +4619,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4062
4619
|
return joined;
|
|
4063
4620
|
}
|
|
4064
4621
|
if (definitionOf(peer)?.permission.trackUnattended) startingUnattended.set(peer, args.unattended === true);
|
|
4065
|
-
const running = startPeerOnce(peer, args).then(async result => {
|
|
4622
|
+
const running = startPeerOnce(peer, args, startCurrent).then(async result => {
|
|
4623
|
+
startCurrent?.();
|
|
4066
4624
|
if (!result.ok && definitionOf(peer)?.permission.trackUnattended) unattendedPeers.delete(peer);
|
|
4067
4625
|
if (!result.ok || result.already) return result;
|
|
4068
4626
|
if (definitionOf(peer)?.session.restoreOnStart) recordSessions();
|
|
4069
4627
|
return permissionChange(peer, async () => {
|
|
4628
|
+
startCurrent?.();
|
|
4070
4629
|
if (definitionOf(peer)?.permission.trackUnattended) { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
|
|
4071
4630
|
logPermissionStart(peer); writeStatus(); return result;
|
|
4072
4631
|
}, true);
|
|
@@ -4122,7 +4681,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4122
4681
|
});
|
|
4123
4682
|
// Printed for a person's explicit reconciliation only; never executed by auto-start.
|
|
4124
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`;
|
|
4125
|
-
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" };
|
|
4126
4687
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
4127
4688
|
if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
4128
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" };
|
|
@@ -4196,12 +4757,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4196
4757
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
4197
4758
|
if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
|
|
4198
4759
|
const owner = bus.peers.get(peer);
|
|
4199
|
-
|
|
4760
|
+
if (owner && "form" in owner && owner.form === undefined) return { ok: false, error: "peer form is unknown; refresh ahub status before stopping it" };
|
|
4761
|
+
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner?.form ?? (owner ? "headless" : startMode(peer)), owner);
|
|
4200
4762
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
4201
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)" };
|
|
4202
|
-
if (!
|
|
4203
|
-
if (
|
|
4204
|
-
if (owner.state === "offline" &&
|
|
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" };
|
|
4765
|
+
if (owner.form === "tui") return { ok: false, error: `${definitionOf(peer)?.displayName ?? peer} belongs to a native TUI terminal; end it in its terminal` };
|
|
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` };
|
|
4205
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" };
|
|
4206
4768
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
4207
4769
|
try {
|
|
@@ -4210,7 +4772,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4210
4772
|
operational?.current();
|
|
4211
4773
|
const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
|
|
4212
4774
|
// Pi persists a session only after its first message: take live empty-session proof before stopping.
|
|
4213
|
-
if (
|
|
4775
|
+
if (owner.captureResume && owner.state === "idle") {
|
|
4214
4776
|
try { await owner.captureResume(); }
|
|
4215
4777
|
catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
|
|
4216
4778
|
}
|
|
@@ -4219,7 +4781,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4219
4781
|
await owner.stop(reason);
|
|
4220
4782
|
writeStatus();
|
|
4221
4783
|
const state = bus.stateOf(peer);
|
|
4222
|
-
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" };
|
|
4223
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 */ }
|
|
4224
4786
|
return { ok: true, state };
|
|
4225
4787
|
});
|
|
@@ -4246,31 +4808,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4246
4808
|
};
|
|
4247
4809
|
}
|
|
4248
4810
|
|
|
4249
|
-
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?.();
|
|
4250
4813
|
let unmute: (() => void) | undefined;
|
|
4251
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" };
|
|
4252
4816
|
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
4253
4817
|
let executionCwd: string;
|
|
4254
|
-
try { executionCwd =
|
|
4818
|
+
try { executionCwd = resolveExecutionCwd(peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
|
|
4255
4819
|
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
4256
4820
|
executionRoots.set(peer, executionCwd);
|
|
4257
4821
|
applyPendingPermissionDefault(peer);
|
|
4258
|
-
if (definitionOf(peer)?.start.refreshTools) { await ensureSpec(true); await refreshRunner(); }
|
|
4259
|
-
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?.();
|
|
4260
4825
|
return result.ok && declarations[peer] ? { ...result, executionCwd } : result;
|
|
4261
4826
|
} finally {
|
|
4262
4827
|
unmute?.();
|
|
4263
4828
|
}
|
|
4264
4829
|
}
|
|
4265
4830
|
|
|
4266
|
-
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?.();
|
|
4267
4833
|
const adapter = definitionOf(peer);
|
|
4268
4834
|
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
|
|
4269
4835
|
&& (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
|
|
4270
4836
|
return { ok: false, error: "configured ACP session selector is invalid" };
|
|
4271
4837
|
}
|
|
4272
4838
|
const retainedOwner = bus.peers.get(peer);
|
|
4273
|
-
if (adapter?.permission.genericAcp
|
|
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" };
|
|
4274
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" };
|
|
4275
4841
|
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
4276
4842
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
@@ -4315,7 +4881,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4315
4881
|
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
4316
4882
|
args = { ...args, ...selected };
|
|
4317
4883
|
mute(existing);
|
|
4318
|
-
await stopPiForReplacement(existing);
|
|
4884
|
+
await stopPiForReplacement(existing); startCurrent?.();
|
|
4319
4885
|
}
|
|
4320
4886
|
if (isPiPeer(existing) && !args.replaceSession) {
|
|
4321
4887
|
let saved = existing.recoveryMetadata();
|
|
@@ -4332,18 +4898,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4332
4898
|
// process from the abandoned CLI cannot claim the replacement owner.
|
|
4333
4899
|
// The replacement's start event is the only state change the console should see (issue #42).
|
|
4334
4900
|
mute(existing);
|
|
4335
|
-
await stopPiForReplacement(existing);
|
|
4901
|
+
await stopPiForReplacement(existing); startCurrent?.();
|
|
4336
4902
|
} else if (changesOwner) {
|
|
4337
|
-
saved = await existing.captureResume();
|
|
4903
|
+
saved = await existing.captureResume(); startCurrent?.();
|
|
4338
4904
|
if (!saved.sessionId) return { ok: false, error: "Pi session identity is not ready for handover" };
|
|
4339
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 };
|
|
4340
4906
|
// Same as the unclaimed handover: no offline flash between the adapters (issue #42).
|
|
4341
4907
|
mute(existing);
|
|
4342
|
-
await stopPiForReplacement(existing);
|
|
4908
|
+
await stopPiForReplacement(existing); startCurrent?.();
|
|
4343
4909
|
} else if (existing.state !== "offline") {
|
|
4344
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 };
|
|
4345
4911
|
} else if (saved.sessionId) {
|
|
4346
|
-
saved = await existing.captureResume();
|
|
4912
|
+
saved = await existing.captureResume(); startCurrent?.();
|
|
4347
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 };
|
|
4348
4914
|
}
|
|
4349
4915
|
}
|
|
@@ -4353,11 +4919,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4353
4919
|
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
4354
4920
|
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
4355
4921
|
}
|
|
4356
|
-
|
|
4922
|
+
const proxy = frontendProxyState(peer, existing);
|
|
4923
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
4924
|
+
return { ok: true, already: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
|
|
4357
4925
|
}
|
|
4358
4926
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
4359
4927
|
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
4360
4928
|
else await existing?.stop();
|
|
4929
|
+
startCurrent?.();
|
|
4361
4930
|
}
|
|
4362
4931
|
if (adapter?.start.createTui && args.mode === "tui") {
|
|
4363
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.
|
|
@@ -4366,6 +4935,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4366
4935
|
if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
|
|
4367
4936
|
// The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
|
|
4368
4937
|
const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
|
|
4938
|
+
startCurrent?.();
|
|
4369
4939
|
if (!verdict.ok) return { ok: false, error: verdict.reason };
|
|
4370
4940
|
if (verdict.note) log(verdict.note);
|
|
4371
4941
|
if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
|
|
@@ -4373,6 +4943,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4373
4943
|
const nativeRoot = nativeStateDir(peer);
|
|
4374
4944
|
mkdirSync(nativeRoot, { recursive: true });
|
|
4375
4945
|
const qwenState = join(nativeRoot, "qwen");
|
|
4946
|
+
startCurrent?.();
|
|
4376
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"]({
|
|
4377
4948
|
cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
4378
4949
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
|
|
@@ -4387,10 +4958,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4387
4958
|
onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
|
|
4388
4959
|
log,
|
|
4389
4960
|
}) }) as QwenDualPeer;
|
|
4390
|
-
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4961
|
+
recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
|
|
4391
4962
|
executionOwners.set(peer, { owner: tui, cwd: executionCwd });
|
|
4392
4963
|
try {
|
|
4393
|
-
await tui.start();
|
|
4964
|
+
startCurrent?.(); await tui.start(); startCurrent?.();
|
|
4394
4965
|
bus.add(tui);
|
|
4395
4966
|
if (tui.state !== "offline") tui.onState?.(tui.state);
|
|
4396
4967
|
} catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
@@ -4400,6 +4971,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4400
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" };
|
|
4401
4972
|
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
4402
4973
|
const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
|
|
4974
|
+
startCurrent?.();
|
|
4403
4975
|
if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
|
|
4404
4976
|
if (jcodeVerdict.note) log(jcodeVerdict.note);
|
|
4405
4977
|
// #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
|
|
@@ -4407,20 +4979,36 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4407
4979
|
// #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
|
|
4408
4980
|
// written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
|
|
4409
4981
|
if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
|
|
4982
|
+
startCurrent?.();
|
|
4410
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,
|
|
4411
4984
|
providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
|
|
4412
4985
|
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
4413
4986
|
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
4414
|
-
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;
|
|
4415
5002
|
jcodeLaunches.set(peer, jcode);
|
|
4416
|
-
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4417
|
-
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); }
|
|
4418
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)"); }
|
|
4419
5006
|
return { ok: true, policy: "unmanaged" };
|
|
4420
5007
|
}
|
|
4421
5008
|
if (adapter?.start.acpCommand) {
|
|
4422
5009
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
4423
5010
|
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
5011
|
+
startCurrent?.();
|
|
4424
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"]({
|
|
4425
5013
|
onActivity: item => activityRings[peer]?.append(item),
|
|
4426
5014
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
@@ -4461,10 +5049,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4461
5049
|
preamble: sessionRoleContract(peer),
|
|
4462
5050
|
}) }) as AcpPeer;
|
|
4463
5051
|
recoveryTaskPreface(peer);
|
|
4464
|
-
await ensurePreface(peer);
|
|
5052
|
+
await ensurePreface(peer); startCurrent?.();
|
|
4465
5053
|
try {
|
|
4466
|
-
await kimi.start();
|
|
4467
|
-
await reconcileNativeStart(peer, kimi);
|
|
5054
|
+
startCurrent?.(); await kimi.start(); startCurrent?.();
|
|
5055
|
+
await reconcileNativeStart(peer, kimi, startCurrent);
|
|
4468
5056
|
} catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
4469
5057
|
return { ok: true };
|
|
4470
5058
|
}
|
|
@@ -4475,12 +5063,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4475
5063
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
4476
5064
|
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
4477
5065
|
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
4478
|
-
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 = {
|
|
4479
5072
|
onActivity: item => activityRings[peer]?.append(item),
|
|
4480
5073
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
4481
5074
|
actionGate: acpActionGate(peer),
|
|
4482
5075
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
4483
|
-
...(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 } : {}) } } : {}),
|
|
4484
5077
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
4485
5078
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
4486
5079
|
autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
|
|
@@ -4495,13 +5088,37 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4495
5088
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
4496
5089
|
mcpServers,
|
|
4497
5090
|
preamble: sessionRoleContract(peer),
|
|
4498
|
-
}
|
|
4499
|
-
if (
|
|
4500
|
-
|
|
4501
|
-
|
|
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);
|
|
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");
|
|
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
|
+
}
|
|
4502
5119
|
catch (error) {
|
|
4503
5120
|
await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
|
|
4504
|
-
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.
|
|
4505
5122
|
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
4506
5123
|
if (error instanceof QwenIdentityError) throw error;
|
|
4507
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)`);
|
|
@@ -4512,9 +5129,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4512
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" };
|
|
4513
5130
|
let codexSteering = false;
|
|
4514
5131
|
actionBudgets.revoke(peer);
|
|
5132
|
+
startCurrent?.();
|
|
4515
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"]({
|
|
4516
5134
|
admitAttach: () => membershipAllowed(peer),
|
|
4517
5135
|
permissionMode: permissionMode(peer),
|
|
5136
|
+
...(declarations[peer] ? { threadSandbox: () => isPlannerOnly(config.roles[peer] ?? []) ? declarations[peer]?.worktree ? "unavailable" as const : "read-only" as const : undefined } : {}),
|
|
4518
5137
|
actionGate: acpActionGate(peer), onPermission,
|
|
4519
5138
|
onTokens: (added) => void addTokens(peer, added),
|
|
4520
5139
|
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
@@ -4580,12 +5199,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4580
5199
|
log,
|
|
4581
5200
|
}) }) as CodexPeer;
|
|
4582
5201
|
recoveryTaskPreface(peer);
|
|
4583
|
-
await ensurePreface(peer);
|
|
5202
|
+
await ensurePreface(peer); startCurrent?.();
|
|
4584
5203
|
try {
|
|
4585
|
-
await codex.start();
|
|
4586
|
-
await reconcileNativeStart(peer, codex);
|
|
5204
|
+
startCurrent?.(); await codex.start(); startCurrent?.();
|
|
5205
|
+
await reconcileNativeStart(peer, codex, startCurrent);
|
|
4587
5206
|
} catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
4588
|
-
|
|
5207
|
+
const proxy = frontendProxyState(peer, codex);
|
|
5208
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
5209
|
+
return { ok: true, ...(proxy.port ? { proxyUrl: proxy.port.url } : {}) };
|
|
4589
5210
|
}
|
|
4590
5211
|
if (adapter?.start.daemonPort === "pi-rpc") {
|
|
4591
5212
|
if (!onRoster(peer)) return { ok: false, error: config.roster.pi === undefined ? "Pi is disabled; set pi.enabled in .agenthub/config.json" : "pi is off roster" };
|
|
@@ -4606,6 +5227,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4606
5227
|
},
|
|
4607
5228
|
});
|
|
4608
5229
|
modelRelays.set(peer, modelRelay);
|
|
5230
|
+
startCurrent?.();
|
|
4609
5231
|
if (!declarations[peer]) globalModelRelay(modelRelay);
|
|
4610
5232
|
const nativeRoot = nativeStateDir(peer);
|
|
4611
5233
|
mkdirSync(nativeRoot, { recursive: true });
|
|
@@ -4669,6 +5291,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4669
5291
|
},
|
|
4670
5292
|
};
|
|
4671
5293
|
const routing = activeRouting();
|
|
5294
|
+
startCurrent?.();
|
|
4672
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"]({
|
|
4673
5296
|
cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
|
|
4674
5297
|
model: args.model,
|
|
@@ -4778,11 +5401,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4778
5401
|
watchdogMs: config.watchdog_ms, maxSteps: config.pi.max_steps, log,
|
|
4779
5402
|
}) }) as PiPeer;
|
|
4780
5403
|
recoveryTaskPreface(peer);
|
|
4781
|
-
await ensurePreface(peer);
|
|
5404
|
+
await ensurePreface(peer); startCurrent?.();
|
|
4782
5405
|
executionOwners.set(peer, { owner: pi, cwd: executionCwd });
|
|
4783
5406
|
bus.add(pi);
|
|
4784
5407
|
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
4785
|
-
try { await pi.start(); } catch (error) {
|
|
5408
|
+
try { startCurrent?.(); await pi.start(); } catch (error) {
|
|
4786
5409
|
await pi.stop().catch((stop: Error) => log(`pi stop after a failed start: ${stop.message}`));
|
|
4787
5410
|
if (error instanceof PiVersionRefusal) return { ok: false, error: sanitize(error.message).slice(0, 1000) };
|
|
4788
5411
|
throw error;
|
|
@@ -4799,9 +5422,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4799
5422
|
// It stays held; holdPeer validates the restored choice before an operator can resume it.
|
|
4800
5423
|
if (recoveryActive() && manualPaused.has(peer)) log("local: availability deferred while controlled recovery retains its manual pause");
|
|
4801
5424
|
else await validateLocalChoice(route, fixedModel);
|
|
5425
|
+
startCurrent?.();
|
|
4802
5426
|
// Validation can await network: a queued delivery may have started meanwhile.
|
|
4803
5427
|
if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
4804
|
-
if (existing) { mute(existing); await existing.stop(); }
|
|
5428
|
+
if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
|
|
4805
5429
|
const capture = config.memory.enabled
|
|
4806
5430
|
? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
|
|
4807
5431
|
: undefined;
|
|
@@ -4843,6 +5467,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4843
5467
|
shellProofs.ordinary(input, current);
|
|
4844
5468
|
return current() ? true : "aborted";
|
|
4845
5469
|
};
|
|
5470
|
+
startCurrent?.();
|
|
4846
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"]({
|
|
4847
5472
|
cwd: executionCwd,
|
|
4848
5473
|
omni,
|
|
@@ -4857,7 +5482,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4857
5482
|
turnId: () => turns.get(peer)?.id,
|
|
4858
5483
|
onTool: (observation, task) => observeProgress(peer, observation, task),
|
|
4859
5484
|
fixedModel: args.model ?? routing.local.fixed_model,
|
|
4860
|
-
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 } } : {}) },
|
|
4861
5490
|
onApprovalStop: reason => notify(reason),
|
|
4862
5491
|
...(capture ? { capture } : {}),
|
|
4863
5492
|
profile: resolvedProfile,
|
|
@@ -4875,24 +5504,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4875
5504
|
log,
|
|
4876
5505
|
}) }) as LocalPeer;
|
|
4877
5506
|
recoveryTaskPreface(peer);
|
|
4878
|
-
await ensurePreface(peer);
|
|
5507
|
+
await ensurePreface(peer); startCurrent?.();
|
|
4879
5508
|
executionOwners.set(peer, { owner: local, cwd: executionCwd });
|
|
4880
5509
|
bus.add(local);
|
|
4881
|
-
await local.start();
|
|
5510
|
+
startCurrent?.(); await local.start();
|
|
4882
5511
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
4883
5512
|
}
|
|
4884
5513
|
if (adapter?.start.daemonPort === "claude-implementer") {
|
|
4885
5514
|
if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
|
|
4886
5515
|
const currentRoot = () => {
|
|
4887
5516
|
const roles = config.roles[peer] ?? [];
|
|
4888
|
-
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;
|
|
4889
5518
|
};
|
|
4890
5519
|
try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
|
|
4891
5520
|
catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
|
|
4892
5521
|
if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
|
|
4893
|
-
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"); }
|
|
4894
5523
|
const selected = permissionMode(peer);
|
|
4895
5524
|
if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
|
|
5525
|
+
startCurrent?.();
|
|
4896
5526
|
const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
|
|
4897
5527
|
cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
|
|
4898
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 } : {}),
|
|
@@ -4905,6 +5535,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4905
5535
|
} catch { return false; }
|
|
4906
5536
|
},
|
|
4907
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),
|
|
4908
5557
|
transcriptPersisted: (task, session) => {
|
|
4909
5558
|
const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
|
|
4910
5559
|
return path ? pathEvidence(path, lstatSync) : "unknown";
|
|
@@ -4917,10 +5566,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4917
5566
|
...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
|
|
4918
5567
|
}, notify,
|
|
4919
5568
|
}) });
|
|
4920
|
-
await implementer.start();
|
|
5569
|
+
startCurrent?.(); await implementer.start();
|
|
4921
5570
|
try {
|
|
5571
|
+
startCurrent?.();
|
|
4922
5572
|
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4923
|
-
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
5573
|
+
recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
|
|
4924
5574
|
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4925
5575
|
executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
|
|
4926
5576
|
return { ok: true };
|
|
@@ -4931,8 +5581,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4931
5581
|
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
4932
5582
|
// #519: each backend proves isolation before admission, then starts one fresh child per review.
|
|
4933
5583
|
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
4934
|
-
if (existing) { mute(existing); await existing.stop(); }
|
|
5584
|
+
if (existing) { mute(existing); await existing.stop(); startCurrent?.(); }
|
|
4935
5585
|
const reviewPolicies = new Map<string, string>();
|
|
5586
|
+
startCurrent?.();
|
|
4936
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"]({
|
|
4937
5588
|
backend, cwd: executionCwd,
|
|
4938
5589
|
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
@@ -4979,9 +5630,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4979
5630
|
},
|
|
4980
5631
|
log,
|
|
4981
5632
|
}) }) as ReviewerPrintPeer;
|
|
4982
|
-
try { await reviewer.start(); }
|
|
5633
|
+
try { startCurrent?.(); await reviewer.start(); startCurrent?.(); }
|
|
4983
5634
|
catch (error) {
|
|
4984
5635
|
await reviewer.stop().catch(() => {});
|
|
5636
|
+
startCurrent?.();
|
|
4985
5637
|
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4986
5638
|
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4987
5639
|
: reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
|
|
@@ -5061,11 +5713,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5061
5713
|
status: () => {
|
|
5062
5714
|
const quota = budget.status();
|
|
5063
5715
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
5716
|
+
const doing = doingRows(ids);
|
|
5064
5717
|
return {
|
|
5065
5718
|
peers: ids.map(peer => {
|
|
5066
5719
|
const summary = bus.queueSummary(peer);
|
|
5067
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,
|
|
5068
|
-
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,
|
|
5069
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 } : {}) })) } : {}) };
|
|
5070
5723
|
}),
|
|
5071
5724
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
@@ -5107,6 +5760,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5107
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);})());
|
|
5108
5761
|
return {
|
|
5109
5762
|
id: row.id, peer: row.peer, state: row.state, revision: row.revision,
|
|
5763
|
+
...roomChat.evidence(row.peer, row.id),
|
|
5110
5764
|
...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
|
|
5111
5765
|
createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
5112
5766
|
envelopeIds: originals.map((env) => env.id),
|
|
@@ -5191,7 +5845,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5191
5845
|
rows: report.rows.map(row => row.private || privatePrs.has(row.pr) ? { private: true, ready: false, tasks: [], after: [], blockers: ["private lifecycle evidence unavailable"] }
|
|
5192
5846
|
: mixed ? { pr: row.pr, tasks: row.tasks.filter(id => !privateIds.has(id)), ready: false, after: [], blockers: ["private dependency evidence unavailable"] }
|
|
5193
5847
|
: { pr: row.pr, tasks: row.tasks, head: row.head, ready: row.ready, blockers: row.blockers, after: row.after,
|
|
5194
|
-
...(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 } : {}) }),
|
|
5195
5849
|
order: mixed ? [] : report.order, ...(report.unordered ? { unordered: mixed ? [] : report.unordered } : {}), conflicts: mixed ? [] : report.conflicts });
|
|
5196
5850
|
}
|
|
5197
5851
|
/** A public read may use host GitHub authentication only for this project's current public scope. */
|
|
@@ -5203,14 +5857,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5203
5857
|
|| githubOriginRepository(opts.cwd) === identity;
|
|
5204
5858
|
}
|
|
5205
5859
|
const publicReadPrivacy = () => JSON.stringify(board.list().map(task => [task.id, tasks.isPii(task), task.history.length]));
|
|
5206
|
-
function operationalCurrent(sock:Sock,acceptedAfterClose=false):()=>void {
|
|
5860
|
+
function operationalCurrent(sock:Sock,acceptedAfterClose=false,committedRecovery=false):()=>void {
|
|
5207
5861
|
const client=sock.data,peer=client.peer;
|
|
5208
5862
|
return ()=>{
|
|
5209
|
-
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");
|
|
5210
5864
|
try {requireConductor(peer,config.roles);}
|
|
5211
5865
|
catch {throw new OperationalAuthorityError("this operation requires the explicit conductor role","conductor");}
|
|
5212
5866
|
};
|
|
5213
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
|
+
}
|
|
5214
5877
|
function admitOperational(sock:Sock,command:string):()=>void {
|
|
5215
5878
|
const current=operationalCurrent(sock);current();
|
|
5216
5879
|
if(!event({type:"agent_cli",peer:sock.data.peer!,command,refused:false}))throw new Error("CLI audit event was not retained");
|
|
@@ -5301,7 +5964,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5301
5964
|
return ta && tb && CONFLICT_STATES.includes(ta.state) && CONFLICT_STATES.includes(tb.state) ? [{ kind: "conflict" as const, tasks: conflict.tasks, peers: conflict.peers }] : [];
|
|
5302
5965
|
}),
|
|
5303
5966
|
];
|
|
5304
|
-
const attentionStatus = status(false, true);
|
|
5967
|
+
const attentionStatus = status(false, true, true);
|
|
5305
5968
|
let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
|
|
5306
5969
|
const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
|
|
5307
5970
|
const usageView = (open: boolean) => {
|
|
@@ -5346,11 +6009,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5346
6009
|
projectId,
|
|
5347
6010
|
instanceId,
|
|
5348
6011
|
attention: attentionStatus.attentionInput,
|
|
6012
|
+
rooms: attentionStatus.rooms,
|
|
5349
6013
|
pet,
|
|
5350
|
-
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 },
|
|
5351
6015
|
boardCapabilities: { backlog: true },
|
|
5352
6016
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
5353
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) } : {}),
|
|
5354
6020
|
boardLegend: Object.entries(peerView).map(([id, peer]) => ({
|
|
5355
6021
|
peer: id, state: peer.state,
|
|
5356
6022
|
permissionMode: "permissionNote" in peer && peer.permissionNote ? "unmanaged" : permissionDisplay(id, bus.peers.get(id)),
|
|
@@ -5361,7 +6027,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5361
6027
|
tasks: boardTasks.map((task) => {
|
|
5362
6028
|
const view = tasks.publicView(task);
|
|
5363
6029
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
5364
|
-
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 } : {}; })() };
|
|
5365
6031
|
}),
|
|
5366
6032
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
5367
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))) }])),
|
|
@@ -5544,6 +6210,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5544
6210
|
const raw = valueAt(next, path.split("."));
|
|
5545
6211
|
const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
|
|
5546
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;
|
|
5547
6214
|
putConfig(layer.raw, path, raw);
|
|
5548
6215
|
}
|
|
5549
6216
|
}
|
|
@@ -5553,7 +6220,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5553
6220
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
5554
6221
|
texts["config.json"] = next["config.json"];
|
|
5555
6222
|
texts["config.local.json"] = next["config.local.json"];
|
|
5556
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
6223
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
|
|
5557
6224
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
5558
6225
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
5559
6226
|
return prepared.fileConfig;
|
|
@@ -5561,7 +6228,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5561
6228
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
5562
6229
|
const texts = configSourceTexts(opts.cwd);
|
|
5563
6230
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
5564
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
6231
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions), authority, scope);
|
|
5565
6232
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
5566
6233
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
5567
6234
|
}
|
|
@@ -5579,7 +6246,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5579
6246
|
const texts = configSourceTexts(opts.cwd);
|
|
5580
6247
|
fingerprint = configFingerprint(texts);
|
|
5581
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" };
|
|
5582
|
-
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
6249
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
|
|
5583
6250
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
5584
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" };
|
|
5585
6252
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -5605,6 +6272,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5605
6272
|
config.permission_default_sources = candidate.permission_default_sources;
|
|
5606
6273
|
for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
|
|
5607
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();
|
|
5608
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 });
|
|
5609
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);
|
|
5610
6278
|
Object.assign(limits, config.limits);
|
|
@@ -5614,6 +6282,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5614
6282
|
desiredRouting = structuredClone(prepared.routing);
|
|
5615
6283
|
acknowledgeConfiguration(sources, prepared.accepted);
|
|
5616
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();
|
|
5617
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! }));
|
|
5618
6287
|
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
5619
6288
|
retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
|
|
@@ -5711,13 +6380,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5711
6380
|
return undefined;
|
|
5712
6381
|
}
|
|
5713
6382
|
function codexResumeCommand(peer: string): string {
|
|
5714
|
-
const owner = bus.peers.get(peer), thread =
|
|
6383
|
+
const owner = bus.peers.get(peer), thread = owner?.nativeSession ?? lastCodexThreads.get(peer);
|
|
5715
6384
|
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
5716
6385
|
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
5717
6386
|
}
|
|
5718
6387
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
5719
6388
|
const owner = bus.peers.get(peer);
|
|
5720
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer,
|
|
6389
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner?.form, codexResumeCommand(peer));
|
|
5721
6390
|
}
|
|
5722
6391
|
|
|
5723
6392
|
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: SettingSource, confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
|
|
@@ -5749,10 +6418,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5749
6418
|
if (!after) {
|
|
5750
6419
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
5751
6420
|
const owner = bus.peers.get(peer);
|
|
5752
|
-
if (
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
6421
|
+
if (owner?.detachForRoster && owner.form !== "headless") {
|
|
6422
|
+
await owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
|
|
6423
|
+
if (definitionOf(peer)?.attach?.admission) clearKimiAdmission();
|
|
6424
|
+
}
|
|
6425
|
+
else if (owner && (owner.state !== "offline" || isExternalInstance(peer) && owner.pendingOwnedProcess !== false)) {
|
|
5756
6426
|
const result = await stopPeer(peer, true, authority==="conductor"?operationalContext.getStore():undefined);
|
|
5757
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`);
|
|
5758
6428
|
}
|
|
@@ -5766,7 +6436,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5766
6436
|
handoff = await tasks.reassignForRoster(peer, handoffCurrent);
|
|
5767
6437
|
} else {
|
|
5768
6438
|
const owner = bus.peers.get(peer);
|
|
5769
|
-
if (
|
|
6439
|
+
if (owner?.form !== "headless") await owner?.resumeFromRoster?.();
|
|
5770
6440
|
}
|
|
5771
6441
|
checkOperational(authority);
|
|
5772
6442
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
@@ -5940,7 +6610,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5940
6610
|
if (file !== "config.local.json") return;
|
|
5941
6611
|
const texts = configSourceTexts(opts.cwd);
|
|
5942
6612
|
texts["config.local.json"] = text;
|
|
5943
|
-
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
6613
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters, externalDefinitions);
|
|
5944
6614
|
const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
5945
6615
|
// Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
|
|
5946
6616
|
checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
|
|
@@ -6041,7 +6711,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6041
6711
|
case "start_peer": {
|
|
6042
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.
|
|
6043
6713
|
if (!peer()) return bad;
|
|
6044
|
-
const result = await hubStart(a.peer, "the dashboard");
|
|
6714
|
+
const result = await hubStart(a.peer, "the dashboard", session?.liveTest !== true);
|
|
6045
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)}` };
|
|
6046
6716
|
// A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
|
|
6047
6717
|
if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
|
|
@@ -6066,6 +6736,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6066
6736
|
}
|
|
6067
6737
|
}
|
|
6068
6738
|
|
|
6739
|
+
const recoverySessionDigest = (id: string | undefined) => id === undefined ? undefined : createHash("sha256").update(id).digest("hex");
|
|
6069
6740
|
const recoveryView = () => ({
|
|
6070
6741
|
operationId: recoveryOperationId,
|
|
6071
6742
|
phase: recoveryPhase,
|
|
@@ -6075,25 +6746,33 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6075
6746
|
blockers: [
|
|
6076
6747
|
...(starting.size ? ["peer startup in progress"] : []),
|
|
6077
6748
|
...(!budget.recoverySettled ? ["budget transition in progress"] : []),
|
|
6078
|
-
...([...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`)),
|
|
6079
6750
|
...(permissions.size ? ["pending approvals"] : []),
|
|
6080
6751
|
// #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
|
|
6081
6752
|
...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
|
|
6082
6753
|
...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
|
|
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"] : []),
|
|
6083
6757
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
6084
6758
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
6085
6759
|
...(releasing ? ["ready tasks being released"] : []),
|
|
6086
|
-
...([...bus.peers].filter(([, peer]) =>
|
|
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`)),
|
|
6087
6761
|
],
|
|
6088
6762
|
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
6089
6763
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
6090
6764
|
const now = recoveryPeers()[peer.id];
|
|
6091
|
-
|
|
6092
|
-
|
|
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 }) }];
|
|
6093
6771
|
})),
|
|
6094
6772
|
});
|
|
6095
6773
|
const recoveryError = (error: string) => ({ t: "recovery", ok: false, error });
|
|
6096
|
-
async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
|
|
6774
|
+
async function recoveryOp(msg: any, checkCurrent: () => void = () => {}): Promise<Record<string, unknown>> {
|
|
6775
|
+
checkCurrent();
|
|
6097
6776
|
if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
|
|
6098
6777
|
if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
|
|
6099
6778
|
const incompatibleCancellationTarget = () => {
|
|
@@ -6102,6 +6781,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6102
6781
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
6103
6782
|
};
|
|
6104
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";
|
|
6105
6787
|
const cancelled = incompatibleCancellationTarget();
|
|
6106
6788
|
if (cancelled) return cancelled;
|
|
6107
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";
|
|
@@ -6120,8 +6802,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6120
6802
|
}
|
|
6121
6803
|
|
|
6122
6804
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
6123
|
-
for (const
|
|
6124
|
-
|
|
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");
|
|
6806
|
+
for (const peer of bus.peers.values()) if (peer.captureResume && peer.state === "idle") {
|
|
6807
|
+
try { await peer.captureResume(); checkCurrent(); } catch (error) { return recoveryError((error as Error).message); }
|
|
6125
6808
|
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
6126
6809
|
}
|
|
6127
6810
|
}
|
|
@@ -6146,8 +6829,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6146
6829
|
budget.setRecoveryHold(true);
|
|
6147
6830
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
6148
6831
|
await bus.fenceRecovery();
|
|
6832
|
+
checkCurrent();
|
|
6149
6833
|
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
6150
|
-
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);
|
|
6151
6835
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
6152
6836
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
6153
6837
|
recoveryPhase = "prepared";
|
|
@@ -6167,7 +6851,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6167
6851
|
recoveryPhase = undefined;
|
|
6168
6852
|
recoveryPeerSnapshot = undefined;
|
|
6169
6853
|
clearTimeout(recoveryLeaseTimer);
|
|
6170
|
-
budget.setRecoveryHold(false);
|
|
6854
|
+
budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
|
|
6171
6855
|
writeStatus();
|
|
6172
6856
|
return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
|
|
6173
6857
|
}
|
|
@@ -6207,11 +6891,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6207
6891
|
}
|
|
6208
6892
|
if (msg.op === "release") {
|
|
6209
6893
|
if (recoveryPhase === "released") {
|
|
6210
|
-
bus.setRecoveryHold(false); budget.setRecoveryHold(false);
|
|
6894
|
+
bus.setRecoveryHold(false); budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
|
|
6211
6895
|
return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
|
|
6212
6896
|
}
|
|
6213
6897
|
if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
|
|
6214
6898
|
await bus.fenceRecovery();
|
|
6899
|
+
checkCurrent();
|
|
6215
6900
|
const current = recoveryPeers();
|
|
6216
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"));
|
|
6217
6902
|
if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
|
|
@@ -6223,30 +6908,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6223
6908
|
recoveryPhase = "released";
|
|
6224
6909
|
clearTimeout(recoveryLeaseTimer);
|
|
6225
6910
|
bus.setRecoveryHold(false);
|
|
6226
|
-
budget.setRecoveryHold(false);
|
|
6911
|
+
budget.setRecoveryHold(false); tasks.scheduleQueuePressureRecheck();
|
|
6227
6912
|
writeStatus();
|
|
6228
6913
|
return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
|
|
6229
6914
|
}
|
|
6230
6915
|
return recoveryError("unknown recovery operation");
|
|
6231
6916
|
}
|
|
6232
6917
|
|
|
6233
|
-
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
6234
|
-
if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
|
|
6235
|
-
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6236
|
-
return row?.permissionHook ? row : undefined;
|
|
6237
|
-
}
|
|
6238
6918
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
6239
6919
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
6240
6920
|
if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
6241
6921
|
if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
|
|
6242
|
-
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (isWsPeer(bus.peers.get(peer)) || (!bus.peers.has(peer) && startMode(peer) === "tui"))) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
|
|
6243
|
-
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
6244
6922
|
const owner = bus.peers.get(peer);
|
|
6923
|
+
const unmanagedForm = owner ? owner.form === "tui" || "form" in owner && owner.form === undefined : startMode(peer) === "tui";
|
|
6924
|
+
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && unmanagedForm) return { ok: false, error: definitionOf(peer)!.permission.unmanagedNote };
|
|
6925
|
+
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
6926
|
+
const proxy = frontendProxyState(peer, owner);
|
|
6927
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
6245
6928
|
if (mode === "ask" && (!owner || owner.state === "offline")) {
|
|
6246
6929
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
6247
6930
|
// to it would run a mode the status no longer shows.
|
|
6248
6931
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
6249
|
-
const note =
|
|
6932
|
+
const note = proxy.port && !proxy.port.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
|
|
6250
6933
|
if (note) log(`permission mode codex: ${note}`);
|
|
6251
6934
|
const from = permissionMode(peer);
|
|
6252
6935
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -6254,17 +6937,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6254
6937
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
6255
6938
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
6256
6939
|
}
|
|
6257
|
-
if (
|
|
6940
|
+
if (proxy.port && !proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
6258
6941
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
6259
6942
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
6260
6943
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
6261
6944
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
6262
6945
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
6263
|
-
const launch =
|
|
6264
|
-
if (!launch ||
|
|
6946
|
+
const launch = claudeLaunchProof.permissionLaunch(peer);
|
|
6947
|
+
if (!launch || claudeLaunchProof.owner(peer) !== owner || !claudeLaunchProof.verified(peer)) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
6265
6948
|
if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
6266
|
-
} else if (
|
|
6267
|
-
if (!
|
|
6949
|
+
} else if (proxy.port) {
|
|
6950
|
+
if (!proxy.port.attached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
6268
6951
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
6269
6952
|
}
|
|
6270
6953
|
const from = permissionMode(peer);
|
|
@@ -6294,8 +6977,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6294
6977
|
writeStatus(); return { ok: true, peer, permissionMode: permissionMode(peer) };
|
|
6295
6978
|
}
|
|
6296
6979
|
const owner = bus.peers.get(peer);
|
|
6980
|
+
const proxy = frontendProxyState(peer, owner);
|
|
6981
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
6297
6982
|
if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
|
|
6298
|
-
if (!starting.has(peer) &&
|
|
6983
|
+
if (!starting.has(peer) && proxy.port && owner?.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
6299
6984
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
6300
6985
|
const result = await changePermission(peer, "never-ask", true);
|
|
6301
6986
|
if (!result.ok) return result;
|
|
@@ -6322,16 +7007,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6322
7007
|
try { return await action(); } finally { permissionChanges.delete(peer); release(); }
|
|
6323
7008
|
}
|
|
6324
7009
|
|
|
6325
|
-
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?.();
|
|
6326
7012
|
const result = await permissionChange(peer, async () => {
|
|
7013
|
+
startCurrent?.();
|
|
6327
7014
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
6328
7015
|
const selected = permissionMode(peer);
|
|
6329
|
-
|
|
6330
|
-
if (
|
|
6331
|
-
|
|
7016
|
+
const proxy = frontendProxyState(peer, owner);
|
|
7017
|
+
if (proxy.present && proxy.port === undefined) return { ok: false, error: "frontend proxy state unknown" };
|
|
7018
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
|
|
7019
|
+
if (!readOnlyPeer(peer) && owner.permissionModeState !== "unmanaged" && owner.getPermissionMode() !== selected) {
|
|
7020
|
+
if (proxy.port && !proxy.port.attached) proxy.port.setStartupPermissionMode(selected);
|
|
6332
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" }; }
|
|
6333
7035
|
}
|
|
6334
7036
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
7037
|
+
startCurrent?.();
|
|
6335
7038
|
executionOwners.set(peer, { owner, cwd: executionRoots.get(peer) ?? opts.cwd });
|
|
6336
7039
|
bus.add(owner);
|
|
6337
7040
|
// start() finished before the bus installed its state hook: replay the current state to drain queued work and record it.
|
|
@@ -6346,6 +7049,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6346
7049
|
let refusalRecorded=false;
|
|
6347
7050
|
const reply = (body: Record<string, unknown>) => {
|
|
6348
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" };
|
|
6349
7053
|
if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
|
|
6350
7054
|
if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
|
|
6351
7055
|
if(body.authorityRefused===true) {
|
|
@@ -6358,6 +7062,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6358
7062
|
};
|
|
6359
7063
|
if (!c.authed) {
|
|
6360
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");
|
|
6361
7066
|
if (msg.v !== PROTOCOL) {
|
|
6362
7067
|
// An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
|
|
6363
7068
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
@@ -6375,6 +7080,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6375
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.
|
|
6376
7081
|
c.peer = String(msg.peer ?? "");
|
|
6377
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");
|
|
6378
7084
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
6379
7085
|
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
6380
7086
|
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
@@ -6383,6 +7089,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6383
7089
|
c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
|
|
6384
7090
|
if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
|
|
6385
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
|
+
}
|
|
6386
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
|
|
6387
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.
|
|
6388
7102
|
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
@@ -6391,18 +7105,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6391
7105
|
}
|
|
6392
7106
|
} else {
|
|
6393
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");
|
|
6394
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");
|
|
6395
7110
|
const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
|
|
6396
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");
|
|
6397
7113
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
6398
7114
|
if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
|
|
6399
7115
|
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
6400
|
-
|
|
6401
|
-
try {
|
|
6402
|
-
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
6403
|
-
if (launch) currentLaunch = launch.launchId;
|
|
6404
|
-
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
6405
|
-
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
7116
|
+
if (claudeLaunchProof.helloLaunch(c.peer, msg.launchId) === "refuse") return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
6406
7117
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
6407
7118
|
}
|
|
6408
7119
|
let peer = bus.peers.get(c.peer);
|
|
@@ -6420,7 +7131,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6420
7131
|
revokeTerminalExecution(c.peer);
|
|
6421
7132
|
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
6422
7133
|
try {
|
|
6423
|
-
const root =
|
|
7134
|
+
const root = resolveExecutionCwd(c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
|
|
6424
7135
|
if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
|
|
6425
7136
|
executionRoots.set(c.peer, root);
|
|
6426
7137
|
executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
|
|
@@ -6439,13 +7150,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6439
7150
|
}
|
|
6440
7151
|
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
|
|
6441
7152
|
}
|
|
6442
|
-
|
|
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" });
|
|
6443
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" });
|
|
6444
7158
|
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
6445
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
|
+
}
|
|
6446
7170
|
case "permission_request": {
|
|
6447
7171
|
const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
|
|
6448
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;
|
|
6449
7176
|
const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
|
|
6450
7177
|
const pending = new AbortController(), abortPending = () => pending.abort();
|
|
6451
7178
|
launch.signal.addEventListener("abort", abortPending, { once: true });
|
|
@@ -6454,10 +7181,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6454
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();
|
|
6455
7182
|
c.toolsOwner?.owner.keepApprovalAlive?.();
|
|
6456
7183
|
void (async () => {
|
|
6457
|
-
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;
|
|
6458
7185
|
if (updatedInput && current()) return void reply({ ok: true, behavior: "allow", wrapperProtocol: 1, updatedInput });
|
|
6459
7186
|
if (!current()) return void reply({ ok: true, behavior: "deny", message: "permission denied" });
|
|
6460
|
-
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
|
+
});
|
|
6461
7191
|
const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
|
|
6462
7192
|
reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
|
|
6463
7193
|
})().catch(() => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
|
|
@@ -6465,12 +7195,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6465
7195
|
}
|
|
6466
7196
|
case "conductor_operational": {
|
|
6467
7197
|
try {
|
|
6468
|
-
if(!["authorize","logs","export","orphans"].includes(msg.op))return void reply({ok:false,error:"unknown operational request"});
|
|
6469
|
-
const
|
|
6470
|
-
|
|
6471
|
-
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"});
|
|
6472
7202
|
const current=msg.op==="authorize"?operationalCurrent(sock):admitOperational(sock,command);current();
|
|
6473
|
-
if(msg.op==="authorize")return void reply({ok:true,instanceId});
|
|
7203
|
+
if(msg.op==="authorize"||msg.op==="admit")return void reply({ok:true,instanceId});
|
|
6474
7204
|
if(msg.op==="logs" || msg.op==="export") {
|
|
6475
7205
|
const lines=dogfoodLines(join(opts.stateDir,msg.op==="logs"?"hub.log":"events.jsonl"),128*1024);
|
|
6476
7206
|
if(!lines.available)return void reply({ok:false,error:"operational history unavailable"});
|
|
@@ -6523,14 +7253,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6523
7253
|
}
|
|
6524
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"});
|
|
6525
7255
|
if (msg.op === "list") {
|
|
6526
|
-
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" });
|
|
6527
7257
|
return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
|
|
6528
7258
|
}
|
|
6529
7259
|
if (msg.op === "discard") {
|
|
6530
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
|
|
6531
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
|
|
6532
7262
|
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
6533
|
-
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" });
|
|
6534
7265
|
const state = msg.state === undefined ? "queued" : msg.state;
|
|
6535
7266
|
if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
6536
7267
|
const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
|
|
@@ -6544,7 +7275,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6544
7275
|
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
6545
7276
|
try {
|
|
6546
7277
|
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
6547
|
-
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++;
|
|
6548
7281
|
}
|
|
6549
7282
|
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
6550
7283
|
}
|
|
@@ -6559,7 +7292,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6559
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" });
|
|
6560
7293
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
6561
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" });
|
|
6562
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);
|
|
6563
7299
|
writeStatus();
|
|
6564
7300
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id),false,c.role!=="console") });
|
|
6565
7301
|
} catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
|
|
@@ -6631,9 +7367,131 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6631
7367
|
return void reply({ ok: false, error: "unknown execution budget operation" });
|
|
6632
7368
|
} catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
6633
7369
|
}
|
|
6634
|
-
case "
|
|
6635
|
-
|
|
6636
|
-
|
|
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) => {
|
|
6637
7495
|
if (isDatabaseBusy(error)) {
|
|
6638
7496
|
try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
|
|
6639
7497
|
const busy = new RecoveryDatabaseBusy();
|
|
@@ -6642,17 +7500,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6642
7500
|
else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
|
|
6643
7501
|
});
|
|
6644
7502
|
return;
|
|
7503
|
+
}
|
|
6645
7504
|
case "send": {
|
|
6646
7505
|
if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
|
|
6647
7506
|
// A human at the console should not wait out the batch window; agents default to status.
|
|
6648
7507
|
const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
|
|
6649
7508
|
if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
|
|
6650
|
-
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;
|
|
6651
7514
|
const unknown = to?.filter((id) => !bus.knownPeers().includes(id) && membershipAllowed(id)) ?? [];
|
|
6652
7515
|
if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
|
|
6653
7516
|
const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
|
|
6654
7517
|
// Built first: limits count the audience it really has (a reply goes to the parent's sender).
|
|
6655
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" }); }
|
|
6656
7521
|
const offRoster = bus.offRosterRecipients(env);
|
|
6657
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(", ")}` });
|
|
6658
7523
|
const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
|
|
@@ -6662,7 +7527,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6662
7527
|
const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
|
|
6663
7528
|
const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
|
|
6664
7529
|
if (refused) return void reply({ t: "sent", ok: false, error: refused });
|
|
6665
|
-
|
|
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;
|
|
6666
7534
|
if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
|
|
6667
7535
|
if (dropped === "hop") return void reply({ t: "sent", ok: true, targets, dropped, recorded: priority === "fyi",
|
|
6668
7536
|
notice: `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to` });
|
|
@@ -6775,21 +7643,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6775
7643
|
const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
|
|
6776
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 } : {}) } : {}) });
|
|
6777
7645
|
}
|
|
6778
|
-
let nativeClaude = false;
|
|
7646
|
+
let nativeClaude = false, nativeTranscript: string | undefined;
|
|
6779
7647
|
if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
|
|
6780
|
-
|
|
6781
|
-
try {
|
|
6782
|
-
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
6783
|
-
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
|
|
6784
|
-
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
6785
|
-
try {
|
|
6786
|
-
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6787
|
-
if (launch) expectedLaunch = launch.launchId;
|
|
6788
|
-
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
7648
|
+
const expectedLaunch = claudeLaunchProof.expectedLaunch(peer);
|
|
6789
7649
|
const current = claudeSession(peer);
|
|
6790
7650
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
6791
7651
|
if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
|
|
6792
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
7652
|
+
const transcript = nativeTranscript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
6793
7653
|
const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
|
|
6794
7654
|
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
6795
7655
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
|
|
@@ -6799,22 +7659,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6799
7659
|
if (!nativeClaude) return void reply({ t: "facts", ok: false });
|
|
6800
7660
|
}
|
|
6801
7661
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
6802
|
-
|
|
6803
|
-
|
|
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
|
+
}
|
|
7665
|
+
const nativePeer = nativeClaude ? claudeLaunchProof.owner(peer) : undefined;
|
|
7666
|
+
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudeLaunchProof.permissionLaunch(peer) : undefined;
|
|
6804
7667
|
const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
|
|
6805
7668
|
if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
|
|
6806
7669
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
|
|
6807
|
-
if (permission !== undefined &&
|
|
7670
|
+
if (permission !== undefined && nativePeer) claudeLaunchProof.bindGeneration(peer, nativePeer.sessionGeneration);
|
|
6808
7671
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
6809
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`.
|
|
6810
7673
|
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
|
|
6811
|
-
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 } : {}) } : {};
|
|
6812
7678
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
6813
7679
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
6814
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")));
|
|
6815
7681
|
if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
6816
7682
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
6817
|
-
if ((phase === "start" || phase === "pre") &&
|
|
7683
|
+
if ((phase === "start" || phase === "pre") && nativePeer) {
|
|
6818
7684
|
const previous = nativeTurnStart.get(peer);
|
|
6819
7685
|
const current = claudeSession(peer);
|
|
6820
7686
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
@@ -6837,7 +7703,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6837
7703
|
const st = hookStats.get(peer)!;
|
|
6838
7704
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
6839
7705
|
hookStats.delete(peer);
|
|
6840
|
-
if (id &&
|
|
7706
|
+
if (id && nativePeer) nativePeer.nativeStopped(sessionId!);
|
|
6841
7707
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
6842
7708
|
};
|
|
6843
7709
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
@@ -6846,13 +7712,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6846
7712
|
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
6847
7713
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
6848
7714
|
const started = nativeTurnStart.get(peer);
|
|
6849
|
-
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") &&
|
|
7715
|
+
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && !!nativePeer && started.generation === nativePeer.sessionGeneration;
|
|
6850
7716
|
// A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
|
|
6851
7717
|
if (!activeStart) {
|
|
6852
7718
|
const latest = readCompletion();
|
|
6853
7719
|
if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
|
|
6854
7720
|
}
|
|
6855
|
-
if (!started || !activeStart || !
|
|
7721
|
+
if (!started || !activeStart || !nativePeer) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
6856
7722
|
const scopeCurrent = () => {
|
|
6857
7723
|
const now = claudeSession(peer);
|
|
6858
7724
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
@@ -7018,7 +7884,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7018
7884
|
if (tasks.isPii(now)) return { id: now.id, title: tasks.publicTitle(now), private: true };
|
|
7019
7885
|
if (task.history.length !== now.history.length || !statuses.has(task.id)) throw new Error("task changed");
|
|
7020
7886
|
const status = statuses.get(task.id)!;
|
|
7021
|
-
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])]));
|
|
7022
7888
|
return { id: now.id, title: tasks.publicTitle(now), receipt: publicReadValue(tasks.publicView(now).sdlc), ...safeStatus };
|
|
7023
7889
|
});
|
|
7024
7890
|
current(); reply({ ok: true, public: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
|
|
@@ -7080,8 +7946,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7080
7946
|
})().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
|
|
7081
7947
|
return;
|
|
7082
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
|
+
}
|
|
7083
8011
|
case "status":
|
|
7084
|
-
return void reply({ t: "status", status: status(c.role === "console") });
|
|
8012
|
+
return void reply({ t: "status", status: status(c.role === "console", false, true) });
|
|
7085
8013
|
case "cli_audit_ack": {
|
|
7086
8014
|
if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
|
|
7087
8015
|
if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
|
|
@@ -7102,9 +8030,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7102
8030
|
void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
|
|
7103
8031
|
return;
|
|
7104
8032
|
case "task_catalog_role":
|
|
7105
|
-
|
|
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" });
|
|
7106
8035
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding task role changes" });
|
|
7107
|
-
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"}` }); }
|
|
7108
8037
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
7109
8038
|
case "settings_get":
|
|
7110
8039
|
if (c.role !== "console") {
|
|
@@ -7167,7 +8096,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7167
8096
|
case "peer_start":
|
|
7168
8097
|
// #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
|
|
7169
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" });
|
|
7170
|
-
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" }); });
|
|
7171
8100
|
return;
|
|
7172
8101
|
case "peer_stop":
|
|
7173
8102
|
let stopCurrent:(()=>void)|undefined;
|
|
@@ -7186,13 +8115,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7186
8115
|
});
|
|
7187
8116
|
return;
|
|
7188
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
|
+
}
|
|
7189
8129
|
if (c.role === "tools" && msg.op === "release_show") {
|
|
7190
8130
|
try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
|
|
7191
8131
|
catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
|
|
7192
8132
|
}
|
|
7193
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" });
|
|
7194
8134
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
7195
|
-
(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(
|
|
7196
8146
|
(text) => reply({ t: "task", ok: true, text }),
|
|
7197
8147
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
7198
8148
|
);
|
|
@@ -7205,23 +8155,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7205
8155
|
return;
|
|
7206
8156
|
}
|
|
7207
8157
|
case "goal_link":
|
|
7208
|
-
// #258: a person links a board task to a long-term task (`ahub goal link`); the link goes through Tasks.
|
|
7209
|
-
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" });
|
|
7210
8160
|
try {
|
|
7211
|
-
|
|
8161
|
+
if (c.role === "tools") admitOperational(sock, "goal link");
|
|
8162
|
+
const text = tasks.linkGoal(c.role === "console" ? USER : c.peer!, msg.task, msg.goal);
|
|
7212
8163
|
const linked = board.get(Number(msg.task));
|
|
7213
8164
|
if (linked?.state === "in_progress" && !tasks.isPii(linked)) renewLeases(linked); // already being worked on: hold it now
|
|
7214
8165
|
return void reply({ t: msg.t, ok: true, text });
|
|
7215
8166
|
}
|
|
7216
|
-
catch (e) { return void reply({ t: msg.t,
|
|
7217
|
-
case "ask":
|
|
8167
|
+
catch (e) { return void reply({ t: msg.t, ...operationalFailure(e) }); }
|
|
8168
|
+
case "ask": {
|
|
7218
8169
|
// Console only: the evidence may hold PII task text (on campus), and the answer is for the person at the terminal.
|
|
7219
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
|
+
});
|
|
7220
8184
|
ask(String(msg.question ?? ""), {
|
|
7221
8185
|
board,
|
|
7222
8186
|
isPii: (t) => tasks.isPii(t),
|
|
7223
8187
|
onCampus,
|
|
7224
|
-
|
|
8188
|
+
current: askCurrent,
|
|
8189
|
+
privateContext: callerPrivate,
|
|
8190
|
+
bindTaskEvidence: rows => { evidencePrivacy = rows; },
|
|
8191
|
+
corpus: keys => publicCorpus(keys.join(" "), askCurrent),
|
|
8192
|
+
isPiiText,
|
|
7225
8193
|
...(config.memory.enabled ? { memory } : {}),
|
|
7226
8194
|
// #258: entries that match the question, then the graph neighbourhood of what they are about.
|
|
7227
8195
|
...(memoryStore ? { native: (keys: string[]) => {
|
|
@@ -7237,6 +8205,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7237
8205
|
...(inference ? { inference } : {}),
|
|
7238
8206
|
})
|
|
7239
8207
|
.then(async (res) => {
|
|
8208
|
+
if (!askCurrent()) return void reply({ t: "ask", ok: true, found: false, evidence: [], pii: true, note: "evidence context changed" });
|
|
7240
8209
|
let saved: string | undefined;
|
|
7241
8210
|
if (msg.remember) {
|
|
7242
8211
|
if (res.pii) saved = "not saved: PII is involved";
|
|
@@ -7257,6 +8226,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7257
8226
|
})
|
|
7258
8227
|
.catch((e: Error) => reply({ t: "ask", ok: false, error: e.message }));
|
|
7259
8228
|
return;
|
|
8229
|
+
}
|
|
7260
8230
|
case "budget":
|
|
7261
8231
|
if (c.role !== "console") {
|
|
7262
8232
|
if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
|
|
@@ -7333,6 +8303,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7333
8303
|
}
|
|
7334
8304
|
async function stopOnce(): Promise<void> {
|
|
7335
8305
|
stopping = true;
|
|
8306
|
+
roomCommands.stop();
|
|
8307
|
+
tasks.stopQueuePressureRechecks();
|
|
7336
8308
|
let implementerShellCleanupFailed = false;
|
|
7337
8309
|
const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
|
|
7338
8310
|
terminalLaunches.clear();
|
|
@@ -7349,10 +8321,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7349
8321
|
dashboard?.stop();
|
|
7350
8322
|
stopConfigWatch();
|
|
7351
8323
|
clearInterval(sweepTimer);
|
|
8324
|
+
clearInterval(schedulesTimer);
|
|
8325
|
+
tasks.stopCiWatchers(); // #785 P1b: no per-head CI watcher outlives the hub
|
|
7352
8326
|
for (const i of intervals) clearInterval(i);
|
|
7353
8327
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
7354
8328
|
for (const p of permissions.values()) p.done(undefined);
|
|
7355
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
|
+
}
|
|
7356
8337
|
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
7357
8338
|
await terminalLaunches.settled();
|
|
7358
8339
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
@@ -7370,10 +8351,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7370
8351
|
await runnerStopped;
|
|
7371
8352
|
await Promise.all([...modelRelays.values()].map(relay => relay.close()));
|
|
7372
8353
|
await egress?.close();
|
|
8354
|
+
await webEgress?.close();
|
|
7373
8355
|
await Promise.all([...piReceiptLedgers.values()].map(ledger => ledger.close()));
|
|
7374
8356
|
collectClaudeUsage();
|
|
7375
8357
|
for (const id of [...researchPending.keys()]) writeResearch(id); // the peers have stopped, so their turns have ended
|
|
7376
8358
|
budget.close();
|
|
8359
|
+
schedulesStore.close();
|
|
8360
|
+
folderStore.close();
|
|
7377
8361
|
conductorHolds.close();
|
|
7378
8362
|
executionBudget.close();
|
|
7379
8363
|
syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
|
|
@@ -7417,7 +8401,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7417
8401
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
7418
8402
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
7419
8403
|
ready = true;
|
|
7420
|
-
|
|
8404
|
+
tasks.scheduleQueuePressureRecheck();
|
|
8405
|
+
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|
|
7421
8406
|
const path = join(opts.cwd, ".agenthub", name);
|
|
7422
8407
|
const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
|
|
7423
8408
|
if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
|
|
@@ -7458,7 +8443,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7458
8443
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
7459
8444
|
// #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
|
|
7460
8445
|
void sweepAgentDrift();
|
|
7461
|
-
|
|
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)) };
|
|
7462
8448
|
} finally {
|
|
7463
8449
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
7464
8450
|
}
|