@staix/agent-hub 0.12.43 → 0.12.45
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 +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
2
|
+
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
3
|
+
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
1
4
|
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
2
5
|
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
3
6
|
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
@@ -20,17 +23,17 @@ import { buildReviewBrief } from "./review-brief.ts";
|
|
|
20
23
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
21
24
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
22
25
|
import { ActivityRing } from "./activity.ts";
|
|
23
|
-
import { JcodeOptionMismatch, JcodePeer } from "../adapters/jcode.ts";
|
|
26
|
+
import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
|
|
24
27
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
25
28
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
26
|
-
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
27
|
-
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
29
|
+
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
30
|
+
import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
|
|
28
31
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
29
32
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
30
33
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
31
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
32
|
-
import { isSupportedPeer as registrySupportedPeer, definitionOf as
|
|
33
|
-
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
34
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
35
|
+
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";
|
|
36
|
+
import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
34
37
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
35
38
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
36
39
|
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
@@ -45,16 +48,16 @@ import { childEnv } from "./child-process.ts";
|
|
|
45
48
|
import { runCheck } from "./checks.ts";
|
|
46
49
|
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
47
50
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
48
|
-
import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
51
|
+
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
49
52
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
50
53
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
51
54
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
52
55
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
53
56
|
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
54
57
|
import type { ServerWebSocket } from "bun";
|
|
55
|
-
import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
56
|
-
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
57
|
-
import { PiPeer } from "../adapters/pi.ts";
|
|
58
|
+
import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
59
|
+
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
60
|
+
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
58
61
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
59
62
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
60
63
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
@@ -64,8 +67,8 @@ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, v
|
|
|
64
67
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
65
68
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
66
69
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
67
|
-
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
68
|
-
import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
70
|
+
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
71
|
+
import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
69
72
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
70
73
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
71
74
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
@@ -73,7 +76,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
73
76
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
74
77
|
import { Briefs } from "../memory/brief.ts";
|
|
75
78
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
76
|
-
import { Budget,
|
|
79
|
+
import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
77
80
|
import { HUB } from "./envelope.ts";
|
|
78
81
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
79
82
|
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
@@ -91,7 +94,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
91
94
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
92
95
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
93
96
|
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
94
|
-
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
97
|
+
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
95
98
|
import { Bus } from "./bus.ts";
|
|
96
99
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
97
100
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -110,7 +113,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
|
110
113
|
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
111
114
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
112
115
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
113
|
-
import { changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
116
|
+
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";
|
|
114
117
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
115
118
|
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
116
119
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
|
|
@@ -120,7 +123,7 @@ import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, ty
|
|
|
120
123
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
121
124
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
122
125
|
import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
|
|
123
|
-
import { ContextWindows, DEFAULT_CONTEXT,
|
|
126
|
+
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
124
127
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
125
128
|
import { detectTooling } from "./tooling.ts";
|
|
126
129
|
import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
@@ -132,6 +135,8 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
132
135
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
133
136
|
|
|
134
137
|
export interface HubConfig {
|
|
138
|
+
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
139
|
+
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
135
140
|
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
136
141
|
instances?: InstancesConfig;
|
|
137
142
|
watchdog_ms: number;
|
|
@@ -251,7 +256,8 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
251
256
|
export { stateDirFor };
|
|
252
257
|
|
|
253
258
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
254
|
-
const
|
|
259
|
+
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"]);
|
|
260
|
+
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
255
261
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
256
262
|
|
|
257
263
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
@@ -318,11 +324,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
|
|
|
318
324
|
catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
|
|
319
325
|
}
|
|
320
326
|
|
|
321
|
-
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
|
|
327
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
|
|
322
328
|
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
323
|
-
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
329
|
+
const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
|
|
324
330
|
const instances = instanceConfig.instances;
|
|
331
|
+
const adapterCatalog = instanceConfig.catalog;
|
|
325
332
|
const ignored: string[] = [...instanceConfig.ignored];
|
|
333
|
+
const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
|
|
326
334
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
327
335
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
328
336
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
@@ -333,8 +341,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
333
341
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
334
342
|
throw error;
|
|
335
343
|
}
|
|
344
|
+
delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
|
|
345
|
+
delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
|
|
336
346
|
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
337
|
-
peerRosterConfig(file.roster, instances);
|
|
347
|
+
peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
|
|
338
348
|
catalogAssignments(file.catalog_roles);
|
|
339
349
|
profileName(file.profile);
|
|
340
350
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -349,8 +359,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
349
359
|
for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
|
|
350
360
|
if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
|
|
351
361
|
instructionProtection(file.protect_instruction_files);
|
|
352
|
-
permissionDefaults(file.permission_modes, instances); // malformed declarations fail even when trust would drop them
|
|
353
|
-
peerStartConfig(file.peers, instances);
|
|
362
|
+
permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
|
|
363
|
+
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
354
364
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
355
365
|
terminalTemplate(file.terminal?.open);
|
|
356
366
|
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
@@ -358,7 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
358
368
|
delete file.permission_default_sources; // provenance is loader-owned, never a file claim
|
|
359
369
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
360
370
|
if (why) ignored.push(why);
|
|
361
|
-
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
371
|
+
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
362
372
|
return [file];
|
|
363
373
|
});
|
|
364
374
|
if (!files.length) return routingRetired.length ? { ...DEFAULT_CONFIG, retired: [...routingRetired] } : DEFAULT_CONFIG;
|
|
@@ -388,7 +398,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
388
398
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
389
399
|
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
390
400
|
retired.push(...conductorPolicy.notices ?? []);
|
|
391
|
-
const
|
|
401
|
+
const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
402
|
+
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
|
|
392
403
|
const instancePermissions: Record<string, PermissionMode> = {};
|
|
393
404
|
const instanceModes: PeerStartConfig = {};
|
|
394
405
|
for (const [id, declaration] of Object.entries(instances ?? {})) {
|
|
@@ -404,17 +415,19 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
404
415
|
return {
|
|
405
416
|
...DEFAULT_CONFIG,
|
|
406
417
|
...file,
|
|
418
|
+
...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
|
|
419
|
+
...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
|
|
407
420
|
...(instances ? { instances } : {}),
|
|
408
421
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
409
422
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
410
423
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
411
|
-
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances) },
|
|
424
|
+
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
|
|
412
425
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
413
426
|
permission_default_sources: appliedPermissionSources,
|
|
414
427
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
415
428
|
...(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)
|
|
416
|
-
roster: peerRosterConfig(file.roster, instances),
|
|
417
|
-
roles: { ...conductorPolicy.roles, ...extraRoles },
|
|
429
|
+
roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
|
|
430
|
+
roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
|
|
418
431
|
planner: conductorPolicy.planner,
|
|
419
432
|
conductor: conductorPolicy.conductor,
|
|
420
433
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
@@ -432,7 +445,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
432
445
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
433
446
|
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
434
447
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
435
|
-
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances) },
|
|
448
|
+
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
436
449
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
437
450
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
438
451
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
@@ -468,6 +481,8 @@ export interface DaemonOptions {
|
|
|
468
481
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
469
482
|
snapshot?: typeof snapshot;
|
|
470
483
|
snapshotBurstMs?: number;
|
|
484
|
+
/** 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. */
|
|
485
|
+
snapshotClock?: () => number;
|
|
471
486
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
472
487
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
473
488
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
@@ -516,135 +531,6 @@ interface Client {
|
|
|
516
531
|
}
|
|
517
532
|
type Sock = ServerWebSocket<Client>;
|
|
518
533
|
|
|
519
|
-
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
520
|
-
class WsPeer extends BasePeer {
|
|
521
|
-
private generation = crypto.randomUUID();
|
|
522
|
-
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
523
|
-
private claimGeneration = crypto.randomUUID();
|
|
524
|
-
get sessionGeneration(): string { return this.claimGeneration; }
|
|
525
|
-
terminalOwner(launchId: string): { generation: string } | undefined {
|
|
526
|
-
return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
|
|
527
|
-
? { generation: this.claimGeneration } : undefined;
|
|
528
|
-
}
|
|
529
|
-
private delivered = new Set<string>();
|
|
530
|
-
private sock: Sock | undefined;
|
|
531
|
-
private claimed: Sock | undefined;
|
|
532
|
-
private nativeSession: string | undefined;
|
|
533
|
-
private nativeActive = false;
|
|
534
|
-
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
535
|
-
pullOnly = false;
|
|
536
|
-
steer: PeerAdapter["steer"] = undefined;
|
|
537
|
-
lastInboxAt: number | undefined;
|
|
538
|
-
private claimedPullOnly = false;
|
|
539
|
-
private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
|
|
540
|
-
private settleAttachmentWaiters(attached: boolean): void {
|
|
541
|
-
for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
|
|
542
|
-
}
|
|
543
|
-
/** Bounded native-hook observations wait for this exact claim, never a later socket. */
|
|
544
|
-
waitForAttach(generation: string): Promise<boolean> {
|
|
545
|
-
if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
|
|
546
|
-
return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
|
|
547
|
-
}
|
|
548
|
-
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
549
|
-
claim(sock: Sock, pullOnly = false): void {
|
|
550
|
-
this.settleAttachmentWaiters(false);
|
|
551
|
-
this.claimGeneration = crypto.randomUUID();
|
|
552
|
-
this.claimed = sock;
|
|
553
|
-
this.claimedPullOnly = pullOnly;
|
|
554
|
-
}
|
|
555
|
-
/** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
|
|
556
|
-
get channelHeld(): boolean {
|
|
557
|
-
return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
|
|
558
|
-
}
|
|
559
|
-
/** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
|
|
560
|
-
standbyHeld(pullOnly: boolean): boolean {
|
|
561
|
-
return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
|
|
562
|
-
}
|
|
563
|
-
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
564
|
-
* for the id must not take it from a claimant that is still arriving. */
|
|
565
|
-
get claiming(): boolean {
|
|
566
|
-
return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
|
|
567
|
-
}
|
|
568
|
-
attach(sock: Sock): void {
|
|
569
|
-
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
570
|
-
if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
|
|
571
|
-
this.sock?.close(4000, "replaced");
|
|
572
|
-
this.sock = sock;
|
|
573
|
-
this.generation = crypto.randomUUID();
|
|
574
|
-
this.delivered.clear();
|
|
575
|
-
this.nativeSession = undefined;
|
|
576
|
-
this.nativeActive = false;
|
|
577
|
-
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
578
|
-
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
579
|
-
// Pull-only transports expose no steer capability; the bus keeps their important work queued.
|
|
580
|
-
this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
|
|
581
|
-
if (this.state !== "busy" || !envs.length || envs.some(env => env.priority !== "important")) throw new Error("channel steering requires important work in the current busy turn");
|
|
582
|
-
await this.deliver(envs, deliveryId);
|
|
583
|
-
};
|
|
584
|
-
this.lastInboxAt = undefined;
|
|
585
|
-
this.setState("idle");
|
|
586
|
-
this.settleAttachmentWaiters(true);
|
|
587
|
-
}
|
|
588
|
-
detach(sock: Sock): void {
|
|
589
|
-
if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
|
|
590
|
-
if (this.sock !== sock) return;
|
|
591
|
-
this.sock = undefined;
|
|
592
|
-
this.steer = undefined;
|
|
593
|
-
this.nativeActive = false;
|
|
594
|
-
this.pullOnly = false;
|
|
595
|
-
this.setState("offline");
|
|
596
|
-
}
|
|
597
|
-
detachForRoster(): void {
|
|
598
|
-
this.settleAttachmentWaiters(false);
|
|
599
|
-
this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
|
|
600
|
-
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
601
|
-
this.claimed = undefined;
|
|
602
|
-
this.sock = undefined;
|
|
603
|
-
this.steer = undefined;
|
|
604
|
-
this.nativeActive = false;
|
|
605
|
-
this.pullOnly = false;
|
|
606
|
-
this.setState("offline");
|
|
607
|
-
}
|
|
608
|
-
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
609
|
-
if (!this.sock) throw new Error(`${this.id} is offline`);
|
|
610
|
-
if (deliveryId) this.delivered.add(deliveryId);
|
|
611
|
-
this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
|
|
612
|
-
}
|
|
613
|
-
/** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
|
|
614
|
-
notifyQueued(counts: { queued: number; important: number }): void {
|
|
615
|
-
if (!this.sock || !this.pullOnly) return;
|
|
616
|
-
this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
|
|
617
|
-
}
|
|
618
|
-
owns(sock: Sock): boolean { return this.sock === sock; }
|
|
619
|
-
nativeStarted(session: string): void {
|
|
620
|
-
if (!this.sock) return;
|
|
621
|
-
this.nativeSession = session;
|
|
622
|
-
this.nativeActive = true;
|
|
623
|
-
this.setState("busy");
|
|
624
|
-
}
|
|
625
|
-
nativeStopped(session: string): boolean {
|
|
626
|
-
if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
|
|
627
|
-
this.nativeSession = session;
|
|
628
|
-
this.nativeActive = false;
|
|
629
|
-
this.setState("idle");
|
|
630
|
-
return true;
|
|
631
|
-
}
|
|
632
|
-
protected override onWatchdog(): void {
|
|
633
|
-
// A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
|
|
634
|
-
if (this.nativeActive) { this.touch(); return; }
|
|
635
|
-
super.onWatchdog();
|
|
636
|
-
}
|
|
637
|
-
ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
|
|
638
|
-
async start(): Promise<void> {}
|
|
639
|
-
async stop(): Promise<void> {
|
|
640
|
-
this.settleAttachmentWaiters(false);
|
|
641
|
-
this.sock?.close();
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
recoveryMetadata(): Record<string, unknown> {
|
|
645
|
-
return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
534
|
|
|
649
535
|
export async function startDaemon(opts: DaemonOptions) {
|
|
650
536
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -652,26 +538,39 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
652
538
|
const startupCleanup: (() => void)[] = [];
|
|
653
539
|
let ready = false;
|
|
654
540
|
try {
|
|
655
|
-
|
|
541
|
+
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
542
|
+
try { startupLayers = readInstanceConfigLayers(opts.cwd); }
|
|
543
|
+
catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
|
|
544
|
+
const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
|
|
545
|
+
const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
|
|
546
|
+
const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
|
|
547
|
+
const externalIds = new Set(externalAdapters.map(item => item.kind));
|
|
548
|
+
const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
|
|
549
|
+
const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
|
|
550
|
+
: loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
|
|
656
551
|
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
657
552
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
658
553
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
659
554
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
660
|
-
const
|
|
555
|
+
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
|
|
556
|
+
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
557
|
+
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
661
558
|
const extraIds = Object.keys(declarations);
|
|
662
559
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
663
|
-
const channelExtras = extraIds.filter(id =>
|
|
664
|
-
//
|
|
665
|
-
|
|
560
|
+
const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
|
|
561
|
+
// #624: every Claude peer whose own state files the daemon reads, keyed on the Claude transport, never on channel-plugin
|
|
562
|
+
// (agy is a channel-plugin peer with no Claude state): the builtin, then its declared instances.
|
|
563
|
+
const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
|
|
564
|
+
...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
|
|
666
565
|
const executionRoots = new Map<string, string>();
|
|
667
566
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
668
567
|
const reviewerUnavailable = new Map<string, string>();
|
|
669
|
-
const roles = { ...loadedConfig.roles };
|
|
568
|
+
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
670
569
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
671
570
|
const startModesFromInstances: PeerStartConfig = {};
|
|
672
571
|
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
673
572
|
for (const [id, declaration] of Object.entries(declarations)) {
|
|
674
|
-
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
573
|
+
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles, adapterCatalog);
|
|
675
574
|
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
676
575
|
if (declaration.permission_mode !== undefined) {
|
|
677
576
|
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
@@ -679,19 +578,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
679
578
|
}
|
|
680
579
|
}
|
|
681
580
|
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
682
|
-
roster: peerRosterConfig(loadedConfig.roster, declarations),
|
|
683
|
-
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations) },
|
|
684
|
-
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations) },
|
|
581
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
|
|
582
|
+
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
|
|
583
|
+
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
|
|
685
584
|
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
686
585
|
roles, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
|
|
687
586
|
// The resolver and catalogs are bound to this daemon, never published into the process-global builtin registry.
|
|
688
|
-
const definitionOf = (peer: string) =>
|
|
689
|
-
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
690
|
-
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
691
|
-
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
692
|
-
const isSupportedPeer = (peer: unknown): peer is string =>
|
|
587
|
+
const definitionOf = (peer: string) => adapterCatalog.definitionOf(peer, declarations);
|
|
588
|
+
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...configuredIds, ...extraIds];
|
|
589
|
+
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...configuredIds, ...extraIds];
|
|
590
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
591
|
+
const isSupportedPeer = (peer: unknown): peer is string => adapterCatalog.isSupportedPeer(peer, declarations);
|
|
693
592
|
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
694
|
-
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
593
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...configuredIds, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
695
594
|
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
696
595
|
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
697
596
|
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
@@ -700,8 +599,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
700
599
|
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
701
600
|
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
702
601
|
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
703
|
-
const
|
|
704
|
-
const
|
|
602
|
+
const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
|
|
603
|
+
const SETTINGS = projectSettings(declarations, adapterCatalog);
|
|
604
|
+
// #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
|
|
605
|
+
// so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
|
|
606
|
+
const staleRosterKey = (key: unknown): string | undefined => {
|
|
607
|
+
const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
|
|
608
|
+
return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
|
|
609
|
+
};
|
|
610
|
+
const settingDef = (key: unknown): SettingDef | string => {
|
|
611
|
+
const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
|
|
612
|
+
return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
|
|
613
|
+
};
|
|
705
614
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
706
615
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
707
616
|
let rosterChanging: string | undefined;
|
|
@@ -709,7 +618,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
709
618
|
let kimiAdmissionId: string | undefined;
|
|
710
619
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
711
620
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
712
|
-
const
|
|
621
|
+
const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
|
|
622
|
+
const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
|
|
713
623
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
714
624
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
715
625
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -720,7 +630,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
720
630
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
721
631
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
722
632
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
723
|
-
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
|
|
633
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
|
|
724
634
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
725
635
|
const embeddedConfig = structuredClone(config);
|
|
726
636
|
let desiredConfig = structuredClone(config);
|
|
@@ -889,7 +799,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
889
799
|
sock.data.tail?.();
|
|
890
800
|
sock.data.watch?.();
|
|
891
801
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
892
|
-
if (peer
|
|
802
|
+
if (isWsPeer(peer)) {
|
|
893
803
|
peer.detach(sock);
|
|
894
804
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
895
805
|
}
|
|
@@ -925,7 +835,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
925
835
|
// a record left now would make the next ordinary start look like one.
|
|
926
836
|
if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
|
|
927
837
|
const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
|
|
928
|
-
const
|
|
838
|
+
const autoStartAdapter = ADAPTERS.find(adapter => adapter.start.autoStart !== undefined);
|
|
839
|
+
const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
|
|
840
|
+
const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
|
|
929
841
|
const startedAt = Date.now();
|
|
930
842
|
/** What crash recovery did or asks the user to do, for `ahub status`. */
|
|
931
843
|
const crashReport: string[] = [];
|
|
@@ -958,7 +870,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
958
870
|
// silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
|
|
959
871
|
// together. Cached until the board changes: it is asked per recipient of every message.
|
|
960
872
|
let turnFreeNow: boolean | undefined;
|
|
961
|
-
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
|
|
873
|
+
const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; head?: string | null; commit?: string | null; branches?: string; writeScope?: boolean; reflog?: HeadMoveCursor; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null; writeTasks: { id: number; acceptance: number }[]; taskEffect?: boolean; effectsPending?: number }>();
|
|
874
|
+
const ownerWriteStarts = new Map<number, { owner: string; acceptance: number; at: number; root: string; tree: string }>();
|
|
962
875
|
const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
|
|
963
876
|
/**
|
|
964
877
|
* Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
|
|
@@ -1009,7 +922,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1009
922
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
1010
923
|
const requestedPeerStops = new Set<string>();
|
|
1011
924
|
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
1012
|
-
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p
|
|
925
|
+
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1013
926
|
startupCleanup.push(() => bus.closeJournal());
|
|
1014
927
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
1015
928
|
const conductorHolds = new ConductorHolds(join(opts.stateDir, "hub.db"));
|
|
@@ -1035,11 +948,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1035
948
|
bus.setRecoveryHold(false);
|
|
1036
949
|
}
|
|
1037
950
|
const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
|
|
1038
|
-
|
|
951
|
+
// One log line per failure streak: the 1 s retry itself is unchanged, only its repeated line is not.
|
|
952
|
+
let recoveryLeaseRetryLogged = false;
|
|
953
|
+
const armRecoveryLease = (delayMs = 10 * 60_000, retrying = false): void => {
|
|
954
|
+
if (!retrying) recoveryLeaseRetryLogged = false;
|
|
1039
955
|
clearTimeout(recoveryLeaseTimer);
|
|
1040
956
|
recoveryLeaseTimer = setTimeout(() => {
|
|
1041
|
-
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
|
|
1042
|
-
try { bus.setRecoveryHold(false); } catch
|
|
957
|
+
if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) { recoveryLeaseRetryLogged = false; return; }
|
|
958
|
+
try { bus.setRecoveryHold(false); } catch {
|
|
959
|
+
if (!recoveryLeaseRetryLogged) { recoveryLeaseRetryLogged = true; log("recovery lease release deferred; delivery storage must be restored before abort (retrying every second)"); }
|
|
960
|
+
armRecoveryLease(1000, true); return;
|
|
961
|
+
}
|
|
962
|
+
recoveryLeaseRetryLogged = false;
|
|
1043
963
|
log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
|
|
1044
964
|
recoveryOperationId = undefined;
|
|
1045
965
|
recoveryPhase = undefined;
|
|
@@ -1299,8 +1219,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1299
1219
|
if (!peer || peer.state === "offline") return undefined;
|
|
1300
1220
|
let token = checkConnections.get(peer);
|
|
1301
1221
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1222
|
+
const hasGeneration = "sessionGeneration" in peer;
|
|
1223
|
+
const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1224
|
+
if (hasGeneration && native === undefined) return undefined;
|
|
1304
1225
|
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1305
1226
|
};
|
|
1306
1227
|
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
@@ -1400,7 +1321,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1400
1321
|
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1401
1322
|
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1402
1323
|
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1403
|
-
const attached=bus.peers.get(by), peer=attached
|
|
1324
|
+
const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
|
|
1404
1325
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1405
1326
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
1406
1327
|
};
|
|
@@ -1414,6 +1335,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1414
1335
|
return [peer, codes];
|
|
1415
1336
|
})),
|
|
1416
1337
|
instances: () => declarations,
|
|
1338
|
+
adapterCatalog,
|
|
1417
1339
|
reviewOnly: reviewOnlyPeer,
|
|
1418
1340
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1419
1341
|
reviewAuthority: runningReviewAuthority,
|
|
@@ -1434,6 +1356,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1434
1356
|
catalog,
|
|
1435
1357
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1436
1358
|
projectedReview: task => reviewProjection(task),
|
|
1359
|
+
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1360
|
+
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1437
1361
|
reviewBrief: task => {
|
|
1438
1362
|
const root = executionRoot(task.owner ?? "");
|
|
1439
1363
|
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
@@ -1491,7 +1415,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1491
1415
|
sweepHeld: () => stopping || recoveryActive(),
|
|
1492
1416
|
cancellationHeld: () => stopping || recoveryActive(),
|
|
1493
1417
|
offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
|
|
1494
|
-
offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer
|
|
1418
|
+
offerPullOnly: (id) => { const peer = bus.peers.get(id); return isWsPeer(peer) && peer.pullOnly; },
|
|
1495
1419
|
stallNotice: stall => { supervision.taskStalled(stall); },
|
|
1496
1420
|
roles: config.roles,
|
|
1497
1421
|
turnFree,
|
|
@@ -1691,7 +1615,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1691
1615
|
} catch { /* no transcript yet: no readback */ }
|
|
1692
1616
|
};
|
|
1693
1617
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1694
|
-
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer
|
|
1618
|
+
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer: string): string | undefined => {
|
|
1619
|
+
if (definitionOf(peer)?.transport !== "claude-channel") return undefined; // #624: no Claude transcript for another kind
|
|
1695
1620
|
const reported = claudeSession(peer);
|
|
1696
1621
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1697
1622
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
@@ -1736,6 +1661,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1736
1661
|
scopes = new Map();
|
|
1737
1662
|
progress.prune();
|
|
1738
1663
|
factsOn(); // a PII task opening or closing switches tracking at once, not at the next boundary (issue #108)
|
|
1664
|
+
if (t.state !== "in_progress" || ownerWriteStarts.get(t.id)?.owner !== t.owner) ownerWriteStarts.delete(t.id);
|
|
1739
1665
|
if ((h.event === "integration requested" || h.event === "integration unresolved") && /has not stopped/.test(h.note ?? "")) {
|
|
1740
1666
|
for (const m of tasks.cohorts.of(t.id)?.members.values() ?? []) if (m.task !== t.id) log(`turn-free: task #${t.id} waits on ${stopEvidence(m.owner)}`);
|
|
1741
1667
|
}
|
|
@@ -1759,10 +1685,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1759
1685
|
const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
|
|
1760
1686
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1761
1687
|
const p = bus.peers.get(peer);
|
|
1762
|
-
if (p
|
|
1763
|
-
if (definitionOf(peer)?.
|
|
1764
|
-
if (p
|
|
1765
|
-
if (p
|
|
1688
|
+
if (isWsPeer(p) && p.claiming) return undefined;
|
|
1689
|
+
if (definitionOf(peer)?.transport === "claude-channel") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1690
|
+
if (isCodexPeer(p)) return p.thread || undefined;
|
|
1691
|
+
if (isAcpPeer(p) || isPiPeer(p)) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1766
1692
|
return undefined;
|
|
1767
1693
|
};
|
|
1768
1694
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
@@ -1773,8 +1699,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1773
1699
|
const owner = bus.peers.get(peer);
|
|
1774
1700
|
const state = bus.stateOf(peer);
|
|
1775
1701
|
const session = contextSession(peer);
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1702
|
+
const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
|
|
1703
|
+
const generation = generationFenced ? owner.sessionGeneration : undefined;
|
|
1704
|
+
if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
|
|
1778
1705
|
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
1779
1706
|
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
1780
1707
|
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
@@ -1786,7 +1713,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1786
1713
|
: `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
|
|
1787
1714
|
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
1788
1715
|
return new Promise((resolve) => {
|
|
1789
|
-
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!
|
|
1716
|
+
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
|
|
1790
1717
|
const done = (summary: string | undefined) => {
|
|
1791
1718
|
clearTimeout(timer);
|
|
1792
1719
|
checkpointWaits.delete(peer);
|
|
@@ -1814,6 +1741,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1814
1741
|
}
|
|
1815
1742
|
};
|
|
1816
1743
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, instanceBudgetHooks({
|
|
1744
|
+
definitionOf,
|
|
1817
1745
|
pause: (peer) => bus.pause(peer),
|
|
1818
1746
|
resume: (peer) => {
|
|
1819
1747
|
if (!manualPaused.has(peer) && !conductorHolds.has(peer)) bus.resume(peer);
|
|
@@ -1915,7 +1843,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1915
1843
|
if (!snapshotBudget) {
|
|
1916
1844
|
const requested = opts.snapshotBurstMs ?? 250;
|
|
1917
1845
|
const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
|
|
1918
|
-
|
|
1846
|
+
const now = opts.snapshotClock;
|
|
1847
|
+
snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
|
|
1919
1848
|
setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
|
|
1920
1849
|
}
|
|
1921
1850
|
return snapshotBudget;
|
|
@@ -1926,28 +1855,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1926
1855
|
snapshotBudgetNoticed = true;
|
|
1927
1856
|
} else log(`snapshot failed at ${what}: ${(error as Error).message}`);
|
|
1928
1857
|
};
|
|
1929
|
-
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
1858
|
+
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
1930
1859
|
if (!capture) return { ms: 0 };
|
|
1931
1860
|
const t0 = performance.now(), budget = reserveSnapshotBudget();
|
|
1932
|
-
let tree: string | undefined, head: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
1861
|
+
let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
1933
1862
|
try {
|
|
1934
1863
|
repo = peer ? repoFor(peer, budget) : factsRepo;
|
|
1935
1864
|
const before = repo && headState(repo, budget);
|
|
1936
1865
|
tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
|
|
1937
1866
|
const after = repo && headState(repo, budget);
|
|
1938
|
-
head = after?.tree;
|
|
1867
|
+
head = after?.tree; commit = after?.head;
|
|
1939
1868
|
if (!before || !after || before.tree !== after.tree) tree = undefined;
|
|
1940
1869
|
if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
|
|
1941
1870
|
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
1942
1871
|
} catch (error) {
|
|
1943
|
-
tree = undefined; head = undefined; reflog = undefined;
|
|
1872
|
+
tree = undefined; head = undefined; commit = undefined; reflog = undefined;
|
|
1944
1873
|
snapshotFailure(what, error);
|
|
1945
1874
|
}
|
|
1946
|
-
return { tree, head, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
1875
|
+
return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
1947
1876
|
};
|
|
1948
1877
|
function privateSnapshotWindow(): boolean {
|
|
1949
1878
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1950
1879
|
}
|
|
1880
|
+
function ownerWriteRoot(peer: string): string | undefined {
|
|
1881
|
+
if (declarations[peer]) {
|
|
1882
|
+
const bound = executionOwners.get(peer);
|
|
1883
|
+
if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
|
|
1884
|
+
}
|
|
1885
|
+
return executionRoot(peer);
|
|
1886
|
+
}
|
|
1887
|
+
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
1888
|
+
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
1889
|
+
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
1890
|
+
// The directory may contain a plain or still-uncommitted checkout, not just registered refs.
|
|
1891
|
+
if (lstatSync(join(opts.cwd, ".worktrees"), { throwIfNoEntry: false })) return false;
|
|
1892
|
+
return soleWorktree(repo, budget);
|
|
1893
|
+
}
|
|
1894
|
+
function captureOwnerWriteStart(task: Task): void {
|
|
1895
|
+
const accepted = tasks.ownerWriteAcceptance(task), prior = ownerWriteStarts.get(task.id);
|
|
1896
|
+
if (prior && prior.owner === task.owner && prior.acceptance === accepted?.index) return;
|
|
1897
|
+
ownerWriteStarts.delete(task.id);
|
|
1898
|
+
if (!accepted || accepted.index !== task.history.length - 1) return;
|
|
1899
|
+
if (!task.owner || !["implement", "bulk_edit"].includes(task.class) || privateSnapshotWindow()) return;
|
|
1900
|
+
const root = ownerWriteRoot(task.owner);
|
|
1901
|
+
if (root === undefined) return;
|
|
1902
|
+
const captured = snap(`task #${task.id} acceptance`, config.snapshots.enabled, task.owner);
|
|
1903
|
+
if (captured.tree) ownerWriteStarts.set(task.id, { owner: task.owner, acceptance: accepted.index, at: accepted.at, root, tree: captured.tree });
|
|
1904
|
+
}
|
|
1905
|
+
function ownerWriteEvidence(task: Task, since: number): boolean | undefined {
|
|
1906
|
+
if (!task.owner || !turnLog || !config.snapshots.enabled || privateSnapshotWindow()) return undefined;
|
|
1907
|
+
const baseline = ownerWriteStarts.get(task.id);
|
|
1908
|
+
const acceptance = tasks.ownerWriteAcceptance(task)?.index;
|
|
1909
|
+
if (!baseline || baseline.owner !== task.owner || baseline.at !== since || baseline.acceptance !== acceptance || baseline.root !== ownerWriteRoot(task.owner)) return undefined;
|
|
1910
|
+
try {
|
|
1911
|
+
if (turnLog.touchesFor([task.id]).some(touch => touch.peer === task.owner && touch.at >= since)) return true;
|
|
1912
|
+
const canonical = baseline.root === opts.cwd;
|
|
1913
|
+
if (canonical && canonicalWriteUnknown(task)) return undefined;
|
|
1914
|
+
const current = snap(`task #${task.id} done evidence`, true, task.owner);
|
|
1915
|
+
if (!current.repo || !current.tree) return undefined;
|
|
1916
|
+
const paths = knownChangedPaths(current.repo, baseline.tree, current.tree, current.budget);
|
|
1917
|
+
if (paths === undefined) return undefined;
|
|
1918
|
+
if (paths.length) return true;
|
|
1919
|
+
if (canonical && canonicalWriteScope(current.repo, current.budget) !== true) return undefined;
|
|
1920
|
+
return false;
|
|
1921
|
+
} catch { return undefined; } // Unknown is never evidence of no writes, including a missing/pruned tree.
|
|
1922
|
+
}
|
|
1951
1923
|
function reviewProjection(task: Task): string | undefined {
|
|
1952
1924
|
const owner = task.owner ?? "", root = executionRoot(owner);
|
|
1953
1925
|
const repo = repoFor(owner), projections = projectionsFor(owner);
|
|
@@ -1982,7 +1954,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1982
1954
|
}
|
|
1983
1955
|
return n;
|
|
1984
1956
|
};
|
|
1985
|
-
const onKimiTokens = (total: number, session: string, peer
|
|
1957
|
+
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
1986
1958
|
const n = addTokens(peer, delta(peer, total, session));
|
|
1987
1959
|
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
1988
1960
|
const now = Date.now();
|
|
@@ -2045,7 +2017,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2045
2017
|
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
2046
2018
|
claudeContextSeen.set(claudePeer, payload);
|
|
2047
2019
|
const bound = contextSession(claudePeer);
|
|
2048
|
-
|
|
2020
|
+
const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
|
|
2021
|
+
if (reading) contexts.report(claudePeer, reading, bound);
|
|
2049
2022
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
2050
2023
|
}, 1000),
|
|
2051
2024
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -2065,7 +2038,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2065
2038
|
claudeUsageSeen = mtime;
|
|
2066
2039
|
const usage = JSON.parse(readFileSync(file, "utf8"));
|
|
2067
2040
|
// The file's own timestamp, not "now": a file left behind by yesterday's session is a stale reading, not a fresh one.
|
|
2068
|
-
|
|
2041
|
+
for (const adapter of ADAPTERS.filter(adapter => adapter.transport === "claude-channel")) {
|
|
2042
|
+
const windows = readNativeQuota(adapter, usage.rate_limits);
|
|
2043
|
+
if (windows) budget.report(adapter.kind, windows, false, Number(usage.at) || mtime);
|
|
2044
|
+
}
|
|
2069
2045
|
} catch {
|
|
2070
2046
|
// no tee in this session, or a half-written file: next round
|
|
2071
2047
|
}
|
|
@@ -2112,6 +2088,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2112
2088
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
2113
2089
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
2114
2090
|
const [by, op, input] = args;
|
|
2091
|
+
if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
|
|
2092
|
+
const nativeTurn = turns.get(by);
|
|
2093
|
+
const effectful = ownerToolEffect(op, input);
|
|
2094
|
+
if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
|
|
2115
2095
|
const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
|
|
2116
2096
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
2117
2097
|
taskOpsInFlight++;
|
|
@@ -2119,6 +2099,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2119
2099
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
2120
2100
|
const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
|
|
2121
2101
|
const text = await taskOpBody(...args);
|
|
2102
|
+
if (nativeTurn && effectful && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2122
2103
|
const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
|
|
2123
2104
|
if (conducted) {
|
|
2124
2105
|
const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
|
|
@@ -2128,16 +2109,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2128
2109
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2129
2110
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2130
2111
|
} catch (error) {
|
|
2112
|
+
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2131
2113
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2132
2114
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2133
2115
|
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
2134
2116
|
throw error;
|
|
2135
2117
|
} finally {
|
|
2118
|
+
if (nativeTurn && effectful) nativeTurn.effectsPending = Math.max(0, (nativeTurn.effectsPending ?? 1) - 1);
|
|
2136
2119
|
taskOpsInFlight--;
|
|
2137
2120
|
}
|
|
2138
2121
|
};
|
|
2139
2122
|
function ownHeldHint(peer: PeerId): string {
|
|
2140
|
-
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer)
|
|
2123
|
+
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(isLocalPeer(bus.peers.get(peer)) || isAcpPeer(bus.peers.get(peer)))); }
|
|
2141
2124
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
2142
2125
|
}
|
|
2143
2126
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
@@ -2271,7 +2254,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2271
2254
|
switch (op) {
|
|
2272
2255
|
case "hub_delivery_retry": {
|
|
2273
2256
|
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");
|
|
2274
|
-
if (bus.peers.get(by)
|
|
2257
|
+
if (isLocalPeer(bus.peers.get(by)) || isAcpPeer(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
2275
2258
|
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
|
|
2276
2259
|
bus.retryDelivery(by, a.delivery_id);
|
|
2277
2260
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
@@ -2355,7 +2338,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2355
2338
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2356
2339
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2357
2340
|
case "hub_review":
|
|
2358
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2341
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2359
2342
|
case "hub_remember":
|
|
2360
2343
|
return tasks.remember(by, a);
|
|
2361
2344
|
case "hub_checkpoint": {
|
|
@@ -2460,12 +2443,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2460
2443
|
if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2461
2444
|
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`);
|
|
2462
2445
|
const codex = bus.peers.get(turn.peer);
|
|
2463
|
-
if (!(codex
|
|
2446
|
+
if (!isCodexPeer(codex)) throw new Error("codex is not attached");
|
|
2464
2447
|
const workspace = turnLog!.workspace(turn.id);
|
|
2465
2448
|
if (workspace !== undefined) {
|
|
2466
|
-
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances);
|
|
2449
|
+
const current = readInstanceRoleConfig(opts.cwd), adapter = instanceDefinitionOf(turn.peer, current.instances, current.catalog);
|
|
2467
2450
|
if (!current.instances?.[turn.peer] || adapter?.kind !== definitionOf(turn.peer)?.kind) throw new Error("turn workspace declaration changed; inspect before reverting");
|
|
2468
|
-
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles));
|
|
2451
|
+
const validated = resolveInstanceExecutionCwd(opts.cwd, turn.peer, current.instances, effectiveInstanceRoles(turn.peer, current.instances, current.roles, current.catalog), current.catalog);
|
|
2469
2452
|
const bound = executionOwners.get(turn.peer);
|
|
2470
2453
|
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");
|
|
2471
2454
|
}
|
|
@@ -2550,7 +2533,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2550
2533
|
* waited past `budget.stale_min` the status says so, and the person decides. Read-only.
|
|
2551
2534
|
*/
|
|
2552
2535
|
const importantWaitingStatus = (id: PeerId, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2553
|
-
if (!(peer
|
|
2536
|
+
if (!isAcpPeer(peer) || (peer as PeerAdapter).steer || bus.stateOf(id) !== "busy") return {};
|
|
2554
2537
|
const since = bus.oldestQueuedImportantAt(id);
|
|
2555
2538
|
if (since === undefined || Date.now() - since < config.budget.stale_min * 60_000) return {};
|
|
2556
2539
|
const count = bus.queuedImportant(id), minutes = Math.floor((Date.now() - since) / 60_000);
|
|
@@ -2563,7 +2546,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2563
2546
|
};
|
|
2564
2547
|
let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
|
|
2565
2548
|
const recoveryReady = () => {
|
|
2566
|
-
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" || (peer
|
|
2549
|
+
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" || (isPiPeer(peer) && !peer.recoveryReady))) return false;
|
|
2567
2550
|
if (checkPending.size !== 0) return false;
|
|
2568
2551
|
if (!recoveryPeerSnapshot) return true;
|
|
2569
2552
|
const current = recoveryPeers();
|
|
@@ -2593,7 +2576,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2593
2576
|
});
|
|
2594
2577
|
};
|
|
2595
2578
|
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2596
|
-
const claudeSession = (peer
|
|
2579
|
+
const claudeSession = (peer: string): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
2580
|
+
if (definitionOf(peer)?.transport !== "claude-channel") return {}; // #624: Claude state files belong to Claude peers only
|
|
2597
2581
|
try {
|
|
2598
2582
|
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2599
2583
|
if (value.instanceId !== instanceId) return {};
|
|
@@ -2624,7 +2608,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2624
2608
|
const claudeVersions = new Map<string, string>();
|
|
2625
2609
|
const claudeStamps = new Map<string, string>(); // the transcript's size and mtime when its version was read (#115)
|
|
2626
2610
|
const claudeVersion = (): string | undefined => {
|
|
2627
|
-
const path = claudeSession().transcriptPath;
|
|
2611
|
+
const path = claudeSession(defaultChannelPeer(undefined)).transcriptPath;
|
|
2628
2612
|
if (!path) return undefined;
|
|
2629
2613
|
let fd: number | undefined;
|
|
2630
2614
|
try {
|
|
@@ -2728,17 +2712,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2728
2712
|
log(`WARNING permission start ${peer}: ${mode} from ${permissionModeSources.get(peer) ?? "human runtime command"}; effective ${effective}${effective === "unmanaged" ? "; native mode is unmanaged; requested mode not applied" : pending ? "; waits for a person's y in ahub console" : "; matching actions run without asking"}`);
|
|
2729
2713
|
};
|
|
2730
2714
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
2731
|
-
if (
|
|
2732
|
-
if (
|
|
2733
|
-
if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
|
|
2715
|
+
if (readOnlyPeer(id)) return "read-only";
|
|
2716
|
+
if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
|
|
2734
2717
|
const launch = claudePermissionLaunch(id);
|
|
2735
2718
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2736
2719
|
}
|
|
2737
|
-
|
|
2720
|
+
const native = peer?.permissionModeState;
|
|
2721
|
+
// An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
|
|
2722
|
+
if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
|
|
2723
|
+
return native ?? permissionMode(id);
|
|
2738
2724
|
};
|
|
2739
2725
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2740
2726
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
2741
|
-
if (note && peer
|
|
2727
|
+
if (note && isWsPeer(peer) && peer.pullOnly) return { permissionNote: note };
|
|
2742
2728
|
const mode = permissionDisplay(id, peer);
|
|
2743
2729
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2744
2730
|
};
|
|
@@ -2752,6 +2738,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2752
2738
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2753
2739
|
const reservations = board.statusReservations();
|
|
2754
2740
|
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
2741
|
+
for (const peer of configuredIds) if (onRoster(peer)) ids.add(peer);
|
|
2755
2742
|
if (!Object.keys(config.roster).some(peer => !membershipAllowed(peer))) return [...ids];
|
|
2756
2743
|
for (const task of open) for (const peer of [task.owner, task.reviewer]) if (peer && !membershipAllowed(peer)) ids.add(peer);
|
|
2757
2744
|
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));
|
|
@@ -2778,7 +2765,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2778
2765
|
pendingRestart,
|
|
2779
2766
|
retainedConfig,
|
|
2780
2767
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2781
|
-
startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
|
|
2768
|
+
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
2782
2769
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2783
2770
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2784
2771
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
@@ -2787,6 +2774,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2787
2774
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2788
2775
|
protocol: PROTOCOL,
|
|
2789
2776
|
features: DAEMON_FEATURES,
|
|
2777
|
+
configuredAdapters: configuredIds,
|
|
2778
|
+
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
2779
|
+
...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
|
|
2780
|
+
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
2781
|
+
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2790
2782
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2791
2783
|
activeInstances: extraIds,
|
|
2792
2784
|
stopping,
|
|
@@ -2796,7 +2788,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2796
2788
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2797
2789
|
codexProxyPort: opts.codexProxyPort,
|
|
2798
2790
|
peers: Object.fromEntries(
|
|
2799
|
-
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), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(p
|
|
2791
|
+
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), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(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 ?? [] } : {}) }]; }),
|
|
2800
2792
|
),
|
|
2801
2793
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2802
2794
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -3042,8 +3034,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3042
3034
|
let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
|
|
3043
3035
|
function settlePiContinuity(): void {
|
|
3044
3036
|
if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
|
|
3045
|
-
const peer = bus.peers.get(
|
|
3046
|
-
if (!(peer
|
|
3037
|
+
const peer = bus.peers.get(continuityKind);
|
|
3038
|
+
if (!isPiPeer(peer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
|
|
3047
3039
|
const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
|
|
3048
3040
|
if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
|
|
3049
3041
|
const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
|
|
@@ -3072,8 +3064,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3072
3064
|
}
|
|
3073
3065
|
const recordSessions = () => {
|
|
3074
3066
|
if (stopping) return;
|
|
3075
|
-
const pi = bus.peers.get(
|
|
3076
|
-
if (piContinuity.state === "ready" && (!(pi
|
|
3067
|
+
const pi = bus.peers.get(continuityKind);
|
|
3068
|
+
if (piContinuity.state === "ready" && (!isPiPeer(pi) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
|
|
3077
3069
|
piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
|
|
3078
3070
|
}
|
|
3079
3071
|
const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
|
|
@@ -3095,19 +3087,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3095
3087
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
3096
3088
|
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
3097
3089
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
3098
|
-
for (const step of crashPlan(prev.peers, declarations)) {
|
|
3090
|
+
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
3099
3091
|
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; }
|
|
3100
3092
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
3101
3093
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
3102
3094
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
3103
3095
|
if (step.resume) {
|
|
3104
|
-
const r = await start(
|
|
3096
|
+
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
3105
3097
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
3106
3098
|
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
3107
3099
|
} else report(step.how);
|
|
3108
3100
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
3109
|
-
if (step.tui && startMode(
|
|
3110
|
-
const fresh = await start(
|
|
3101
|
+
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
|
|
3102
|
+
const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
3111
3103
|
const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
|
|
3112
3104
|
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
|
|
3113
3105
|
continue;
|
|
@@ -3161,13 +3153,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3161
3153
|
const capture = config.snapshots.enabled && !!turnLog;
|
|
3162
3154
|
const start = capture && !pii ? snap(`the start of ${id}`, config.snapshots.enabled, e.peer) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
3163
3155
|
try { if (capture) turnLog?.begin(id, e.peer, start?.tree, declarations[e.peer] ? executionRoot(e.peer) : undefined); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
3156
|
+
const writeTasks = tasks.ownerTurnTasks(e.peer);
|
|
3157
|
+
let branches: string | undefined, writeScope: boolean | undefined;
|
|
3158
|
+
if (writeTasks.length && start?.repo) try {
|
|
3159
|
+
branches = branchHeads(start.repo, start.budget);
|
|
3160
|
+
writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
|
|
3161
|
+
} catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
|
|
3164
3162
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3165
|
-
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
3163
|
+
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
3166
3164
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
3167
3165
|
} else if (!busy && open) {
|
|
3168
3166
|
turns.delete(e.peer);
|
|
3169
3167
|
let afterTurn: (() => void) | undefined;
|
|
3170
3168
|
let files: number | undefined;
|
|
3169
|
+
let writeProgress: boolean | undefined;
|
|
3171
3170
|
let snapshotMs = open.snapshotMs;
|
|
3172
3171
|
if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
|
|
3173
3172
|
try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
@@ -3189,7 +3188,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3189
3188
|
if (!end.head) throw new Error("end HEAD evidence is unavailable");
|
|
3190
3189
|
const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
|
|
3191
3190
|
if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
|
|
3192
|
-
const checkBudget = () => { if (end.budget &&
|
|
3191
|
+
const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
3193
3192
|
const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
|
|
3194
3193
|
for (const path of dirtyPaths) {
|
|
3195
3194
|
checkBudget();
|
|
@@ -3216,6 +3215,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3216
3215
|
}
|
|
3217
3216
|
}
|
|
3218
3217
|
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
3218
|
+
if (open.writeTasks.length) {
|
|
3219
|
+
if ((files ?? 0) > 0 || open.commit !== undefined && end.commit !== undefined && open.commit !== end.commit) writeProgress = true;
|
|
3220
|
+
else if (open.commit !== undefined && end.commit !== undefined && open.branches !== undefined && end.repo) {
|
|
3221
|
+
try {
|
|
3222
|
+
const branches = branchHeads(end.repo, end.budget);
|
|
3223
|
+
if (branches !== undefined) {
|
|
3224
|
+
if (branches !== open.branches) writeProgress = true;
|
|
3225
|
+
else if (files === 0 && open.writeScope === true) {
|
|
3226
|
+
const scope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(end.repo, end.budget) : true;
|
|
3227
|
+
if (scope === true) writeProgress = false;
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
} catch (error) { snapshotFailure(`the branch refs of ${open.id}`, error); }
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3219
3233
|
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
3220
3234
|
try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
3221
3235
|
// After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
|
|
@@ -3229,6 +3243,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3229
3243
|
tasks.cohorts.turnEnded(e.peer);
|
|
3230
3244
|
}
|
|
3231
3245
|
try { afterTurn?.(); } catch (error) { log(`conflict check after ${open.id}: ${(error as Error).message}`); }
|
|
3246
|
+
if (definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) {
|
|
3247
|
+
const root = ownerWriteRoot(e.peer);
|
|
3248
|
+
const elsewhere = root === opts.cwd && open.writeTasks.some(binding => { const task = board.get(binding.id); return task && canonicalWriteUnknown(task); });
|
|
3249
|
+
const changed = e.state === "idle" && !open.private && root !== undefined && !(writeProgress === false && elsewhere) ? writeProgress : undefined;
|
|
3250
|
+
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
3251
|
+
}
|
|
3232
3252
|
}
|
|
3233
3253
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3234
3254
|
else offlineSince.delete(e.peer);
|
|
@@ -3376,7 +3396,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3376
3396
|
const replacingPi = new Set<PiPeer>();
|
|
3377
3397
|
const piReplacementAuthority = Symbol("console Pi replacement");
|
|
3378
3398
|
/** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
|
|
3379
|
-
const autoStartPi = () => void hubStart(
|
|
3399
|
+
const autoStartPi = () => void hubStart(autoStartAdapter!.kind, "pi.auto_start").then(
|
|
3380
3400
|
(result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
|
|
3381
3401
|
(error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
|
|
3382
3402
|
async function stopPiForReplacement(pi: PiPeer): Promise<void> {
|
|
@@ -3417,7 +3437,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3417
3437
|
const opener: TerminalOpener = opts.terminal?.(config.terminal.open) ?? { available: () => ({ ok: false, why: "this hub was started without a terminal provider" }), open: async () => ({ ok: false, why: "this hub was started without a terminal provider" }) };
|
|
3418
3438
|
const CLI_ENTRY = fileURLToPath(new URL("../cli/main.ts", import.meta.url));
|
|
3419
3439
|
/** `config.peers` follows a settings write (`settingsSet`), so a changed mode applies at the peer's next start. */
|
|
3420
|
-
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations);
|
|
3440
|
+
const startMode = (peer: string): StartMode => startModeOf(config.peers, peer, declarations, adapterCatalog);
|
|
3421
3441
|
const startWords = (peer: string, mode: StartMode): string[] => {
|
|
3422
3442
|
const words = definitionOf(peer)?.cli.startWords(mode) ?? [peer];
|
|
3423
3443
|
return declarations[peer] ? [words[0]!, "--as", peer, ...words.slice(1)] : words;
|
|
@@ -3426,6 +3446,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3426
3446
|
// ponytail: one terminal per peer per 30 s, by the clock. The hub cannot see whether the terminal it opened still
|
|
3427
3447
|
// exists, so a peer that never attaches can be opened again after that; track the provider's handle to do better.
|
|
3428
3448
|
const opening = new Map<string, number>();
|
|
3449
|
+
// #286 AC10: a peer with no native TUI (headless Kimi, Qwen) is watched through `ahub watch <peer>`. A start the hub made
|
|
3450
|
+
// itself that really started such a peer opens that view through the same provider, with fixed words and no caller text.
|
|
3451
|
+
// The headless start has already succeeded: a missing or failing provider never fails it, it only reports the command.
|
|
3452
|
+
const watchOpening = new Map<string, number>();
|
|
3453
|
+
const watchSentence = (watch: unknown): string => {
|
|
3454
|
+
const w = watch as { command?: string; opened?: string } | undefined;
|
|
3455
|
+
return w?.command ? (w.opened ? ` A watch pane is open (${w.command}).` : ` Watch it with ${w.command}.`) : "";
|
|
3456
|
+
};
|
|
3457
|
+
async function openWatchPane(peer: string, result: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
3458
|
+
if (result.ok !== true || result.already || declarations[peer] || !definitionOf(peer)?.capabilities?.watch) return result;
|
|
3459
|
+
const command = `ahub watch ${peer}`;
|
|
3460
|
+
if (Date.now() - (watchOpening.get(peer) ?? 0) < PEER_START_RETRY_MS) return { ...result, watch: { command } };
|
|
3461
|
+
watchOpening.set(peer, Date.now());
|
|
3462
|
+
const opened = await opener.open(`${peer} watch (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, "watch", peer]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
|
|
3463
|
+
if (!opened.ok) {
|
|
3464
|
+
watchOpening.delete(peer);
|
|
3465
|
+
if (opened.detail) log(`terminal provider for ${peer} watch: ${opened.detail}`);
|
|
3466
|
+
try { notify(`${peer}: started headless; the hub opened no watch pane (${opened.why}). Watch it with ${command}`); } catch { /* the start stands */ }
|
|
3467
|
+
return { ...result, watch: { command } };
|
|
3468
|
+
}
|
|
3469
|
+
try { notify(`${peer}: started headless; the hub opened a watch pane running ${command} (${opened.via})`); } catch { /* the pane is open either way */ }
|
|
3470
|
+
return { ...result, watch: { command, opened: opened.via } };
|
|
3471
|
+
}
|
|
3429
3472
|
/** For the dashboard's Start control: what a start of each peer would do now, and for a TUI where its terminal comes from. */
|
|
3430
3473
|
// ponytail: asks the provider on every dashboard poll (a launch-record read and a PATH lookup for Orca); cache it for a few seconds if a page feels it.
|
|
3431
3474
|
const startPlans = () => STARTABLE_PEERS.filter(startable).map((peer) => {
|
|
@@ -3473,8 +3516,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3473
3516
|
// ponytail: an attached terminal Pi only. A terminal launch that the TUI has not claimed yet reads as offline,
|
|
3474
3517
|
// like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
|
|
3475
3518
|
// issued to tell the two apart.
|
|
3476
|
-
if (owner
|
|
3477
|
-
return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
|
|
3519
|
+
if (isPiPeer(owner) && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
|
|
3520
|
+
return openWatchPane(peer, await startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {}));
|
|
3478
3521
|
}
|
|
3479
3522
|
// #559 N1: only the console's own start reopens a frontend on an attached owned-ACP peer. The conductor and the
|
|
3480
3523
|
// dashboard answer `already`, so a repeated call never adds another TUI writer to the live session.
|
|
@@ -3507,11 +3550,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3507
3550
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
3508
3551
|
if (typeof peer !== "string" || !PEER_ID.test(peer)) return { ok: false, error: "unknown peer; run ahub status and choose a hub-owned headless peer" };
|
|
3509
3552
|
const owner = bus.peers.get(peer);
|
|
3510
|
-
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner
|
|
3553
|
+
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3511
3554
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3512
|
-
if (!(owner
|
|
3513
|
-
if (owner
|
|
3514
|
-
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3555
|
+
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3556
|
+
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3557
|
+
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3515
3558
|
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" };
|
|
3516
3559
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3517
3560
|
try {
|
|
@@ -3519,7 +3562,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3519
3562
|
if (stopping || recoveryActive() || bus.peers.get(peer) !== owner) return { ok: false, error: "peer or recovery changed; refresh ahub status before retrying" };
|
|
3520
3563
|
const reason = `requested stop: ahub stop ${peer}`; // ids only, never task or message text
|
|
3521
3564
|
// Pi persists a session only after its first message: take live empty-session proof before stopping.
|
|
3522
|
-
if (owner
|
|
3565
|
+
if (isPiPeer(owner) && owner.state === "idle") {
|
|
3523
3566
|
try { await owner.captureResume(); }
|
|
3524
3567
|
catch { log(`requested stop ${peer}: resume evidence unavailable; later start requires verified persisted history`); }
|
|
3525
3568
|
}
|
|
@@ -3559,7 +3602,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3559
3602
|
try {
|
|
3560
3603
|
if (declarations[peer] && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "declared instances support headless starts only" };
|
|
3561
3604
|
let executionCwd: string;
|
|
3562
|
-
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? []); }
|
|
3605
|
+
try { executionCwd = resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog); }
|
|
3563
3606
|
catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
3564
3607
|
executionRoots.set(peer, executionCwd);
|
|
3565
3608
|
applyPendingPermissionDefault(peer);
|
|
@@ -3573,6 +3616,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3573
3616
|
|
|
3574
3617
|
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>> {
|
|
3575
3618
|
const adapter = definitionOf(peer);
|
|
3619
|
+
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined
|
|
3620
|
+
&& (typeof args.sessionId !== "string" || !args.sessionId.trim() || Buffer.byteLength(args.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(args.sessionId))) {
|
|
3621
|
+
return { ok: false, error: "configured ACP session selector is invalid" };
|
|
3622
|
+
}
|
|
3623
|
+
const retainedOwner = bus.peers.get(peer);
|
|
3624
|
+
if (adapter?.permission.genericAcp && isAcpPeer(retainedOwner) && retainedOwner.pendingOwnedProcess && retainedOwner.state === "offline") return { ok: false, error: "configured ACP previous process teardown is unconfirmed; retry ahub stop before starting again" };
|
|
3625
|
+
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" };
|
|
3576
3626
|
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3577
3627
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3578
3628
|
if (preflight) return { ok: false, error: preflight };
|
|
@@ -3581,7 +3631,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3581
3631
|
const owner = bus.peers.get(peer);
|
|
3582
3632
|
if ((args.mode ?? startMode(peer)) === "tui") {
|
|
3583
3633
|
if (args.mode !== "tui") return { ok: false, error: "Kimi uses its own TUI (pull-only); run ahub kimi in a terminal, or ahub kimi --mode headless", command: "ahub kimi" };
|
|
3584
|
-
if (owner
|
|
3634
|
+
if (isAcpPeer(owner) && owner.state !== "offline") return { ok: false, error: "Kimi is running headless; run ahub stop kimi before starting its TUI" };
|
|
3585
3635
|
clearKimiAdmission();
|
|
3586
3636
|
kimiAdmissionId = crypto.randomUUID();
|
|
3587
3637
|
kimiSessionMode = "tui"; // scoped to this pending launch and its attached session
|
|
@@ -3589,7 +3639,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3589
3639
|
kimiAdmissionExpiry = setTimeout(clearKimiAdmission, 30_000); kimiAdmissionExpiry.unref();
|
|
3590
3640
|
return { ok: true, mode: "tui", pullOnly: true, command: "ahub kimi", admissionId: kimiAdmissionId };
|
|
3591
3641
|
}
|
|
3592
|
-
if (owner
|
|
3642
|
+
if (isWsPeer(owner) && (owner.state !== "offline" || owner.claiming)) return { ok: false, error: "Kimi runs in a terminal; end it there before starting a headless one" };
|
|
3593
3643
|
if (hasKimiHubMcp(opts.cwd)) return { ok: false, error: "Kimi's project MCP file still has agent-hub; a person must run ahub kimi --mode headless to remove it before ACP starts", command: "ahub kimi --mode headless" };
|
|
3594
3644
|
clearKimiAdmission();
|
|
3595
3645
|
}
|
|
@@ -3604,7 +3654,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3604
3654
|
if (args.replaceSession) {
|
|
3605
3655
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3606
3656
|
if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
|
|
3607
|
-
if (!(existing
|
|
3657
|
+
if (!isPiPeer(existing) || !existing.replacementReady || piReceiptLedgers.get(peer)?.unsettled || [...permissions.values()].some(p => p.peer === peer)) return { ok: false, error: "Pi replacement requires an existing offline, settled owner with no live/unknown process, tools, receipts or approvals" };
|
|
3608
3658
|
const selected = validatePiSession(nativeStateDir(peer), executionCwd, args.sessionFile);
|
|
3609
3659
|
const previous = existing.recoveryMetadata();
|
|
3610
3660
|
recordPiReplacement(nativeStateDir(peer), instanceId, previous, selected);
|
|
@@ -3612,7 +3662,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3612
3662
|
mute(existing);
|
|
3613
3663
|
await stopPiForReplacement(existing);
|
|
3614
3664
|
}
|
|
3615
|
-
if (existing
|
|
3665
|
+
if (isPiPeer(existing) && !args.replaceSession) {
|
|
3616
3666
|
let saved = existing.recoveryMetadata();
|
|
3617
3667
|
const launch = saved.launch as Record<string, unknown>;
|
|
3618
3668
|
if ((args.sessionId && saved.sessionId && args.sessionId !== saved.sessionId) || (args.sessionFile && saved.sessionFile && args.sessionFile !== saved.sessionFile)) return { ok: false, error: "Pi already owns a different session; refusing to replace its identity" };
|
|
@@ -3645,10 +3695,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3645
3695
|
if (adapter?.start.reconfigureWhileAttached && existing?.state !== "offline" && existing && (args.model || args.route)) {
|
|
3646
3696
|
if (existing.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
3647
3697
|
} else if (existing && existing.state !== "offline") {
|
|
3648
|
-
|
|
3698
|
+
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
3699
|
+
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
3700
|
+
}
|
|
3701
|
+
return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
3649
3702
|
}
|
|
3650
3703
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
3651
|
-
if (existing
|
|
3704
|
+
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
3652
3705
|
else await existing?.stop();
|
|
3653
3706
|
}
|
|
3654
3707
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
@@ -3664,7 +3717,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3664
3717
|
if (adapter?.start.acpCommand) {
|
|
3665
3718
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
3666
3719
|
const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
|
|
3667
|
-
onActivity: item => activityRings[peer]
|
|
3720
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3668
3721
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3669
3722
|
cmd, kind: adapter.kind,
|
|
3670
3723
|
permissionMode: permissionMode(peer),
|
|
@@ -3693,11 +3746,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3693
3746
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3694
3747
|
const current = contextSession(peer);
|
|
3695
3748
|
if (!current) return;
|
|
3696
|
-
|
|
3697
|
-
observation.contextUsed
|
|
3698
|
-
|
|
3699
|
-
: { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null },
|
|
3700
|
-
current);
|
|
3749
|
+
const reading = readNativeContext(adapter,
|
|
3750
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3751
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3701
3752
|
},
|
|
3702
3753
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3703
3754
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
|
|
@@ -3711,36 +3762,39 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3711
3762
|
} catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
3712
3763
|
return { ok: true };
|
|
3713
3764
|
}
|
|
3714
|
-
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
3715
|
-
//
|
|
3765
|
+
if (adapter?.transport === "acp" && adapter.start.create && adapter.start.launch) {
|
|
3766
|
+
// Configured ACP definitions own immutable argv and policy; builtins retain their verified launch port.
|
|
3716
3767
|
const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
|
|
3717
|
-
userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
|
|
3768
|
+
userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
|
|
3718
3769
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3719
|
-
const permission = permissionMode(peer);
|
|
3770
|
+
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
3720
3771
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3721
|
-
onActivity: item => activityRings[peer]
|
|
3772
|
+
onActivity: item => activityRings[peer]?.append(item),
|
|
3722
3773
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3723
|
-
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission,
|
|
3774
|
+
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3775
|
+
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3724
3776
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3725
3777
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3726
|
-
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3727
|
-
log: () => log(
|
|
3778
|
+
autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
|
|
3779
|
+
log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
|
|
3728
3780
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3729
3781
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3730
3782
|
const current = contextSession(peer); if (!current) return;
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3783
|
+
const reading = readNativeContext(adapter,
|
|
3784
|
+
{ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now());
|
|
3785
|
+
if (reading) contexts.report(peer, reading, current);
|
|
3734
3786
|
},
|
|
3735
3787
|
onTurnFailure: () => { supervisionTurns.delete(peer); },
|
|
3736
3788
|
mcpServers,
|
|
3737
3789
|
preamble: sessionRoleContract(peer),
|
|
3738
3790
|
} } });
|
|
3739
|
-
if (!(owner
|
|
3791
|
+
if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
|
|
3740
3792
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3741
3793
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3742
3794
|
catch (error) {
|
|
3743
|
-
await owner.stop().catch(() => log(
|
|
3795
|
+
await owner.stop().catch(() => log(`${peer}: ACP stop after failed startup was not confirmed`));
|
|
3796
|
+
if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
|
|
3797
|
+
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
3744
3798
|
if (error instanceof QwenIdentityError) throw error;
|
|
3745
3799
|
throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
|
|
3746
3800
|
}
|
|
@@ -3753,7 +3807,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3753
3807
|
admitAttach: () => membershipAllowed(peer),
|
|
3754
3808
|
permissionMode: permissionMode(peer),
|
|
3755
3809
|
onTokens: (added) => void addTokens(peer, added),
|
|
3756
|
-
|
|
3810
|
+
contextReader: adapter.budget?.context,
|
|
3811
|
+
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3757
3812
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
3758
3813
|
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
3759
3814
|
onTurn: (native) => {
|
|
@@ -3802,7 +3857,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3802
3857
|
...HUB_TOOLS.map((name) => [`tools.${name}.approval_mode`, '"approve"']),
|
|
3803
3858
|
].flatMap(([k, v]) => ["-c", `mcp_servers.agent-hub.${k}=${v}`]),
|
|
3804
3859
|
preamble: sessionRoleContract(peer),
|
|
3805
|
-
onUsage: (rateLimits, hard) => budget.report(peer,
|
|
3860
|
+
onUsage: (rateLimits, hard) => { const windows = readNativeQuota(adapter, rateLimits); if (windows) budget.report(peer, windows, hard); },
|
|
3806
3861
|
usagePollMs: config.budget.poll_min * 60_000,
|
|
3807
3862
|
cwd: executionCwd,
|
|
3808
3863
|
watchdogMs: config.watchdog_ms,
|
|
@@ -3828,7 +3883,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3828
3883
|
staySwitch: () => activeRouting(),
|
|
3829
3884
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
3830
3885
|
efficientWaitMs: activeRouting().pi.efficient_wait_ms,
|
|
3831
|
-
underBudget: () => { const current = bus.peers.get(peer); return current
|
|
3886
|
+
underBudget: () => { const current = bus.peers.get(peer); return isPiPeer(current) && tasks.budgetApplies(current.budgetEnvelopes, peer); },
|
|
3832
3887
|
onCooldown: record => {
|
|
3833
3888
|
log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
|
|
3834
3889
|
event({ type: "cooldown", peer, ...record });
|
|
@@ -4112,9 +4167,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4112
4167
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
4113
4168
|
}
|
|
4114
4169
|
|
|
4115
|
-
async function admitPiRequest(peer
|
|
4170
|
+
async function admitPiRequest(peer: string): Promise<{ allowed: boolean; reason?: string; remainingMs?: number }> {
|
|
4116
4171
|
const current = bus.peers.get(peer);
|
|
4117
|
-
if (!(current
|
|
4172
|
+
if (!isPiPeer(current)) return { allowed: false, reason: "Pi owner is not active" };
|
|
4118
4173
|
const decisions = tasks.admitExecutionEnvelopes(current.budgetEnvelopes, peer, "model_calls");
|
|
4119
4174
|
const denied = decisions.find(d => !d.allowed);
|
|
4120
4175
|
const stop = denied ? current.recordBudgetStop(denied) : undefined;
|
|
@@ -4147,7 +4202,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4147
4202
|
} else {
|
|
4148
4203
|
if (budget.record(id)) return { ok: false, error: `${id} is paused by the budget coordinator until its window resets (ahub budget); to override: ahub budget resume ${id}` };
|
|
4149
4204
|
const peer = bus.peers.get(id);
|
|
4150
|
-
if (peer
|
|
4205
|
+
if (isLocalPeer(peer)) {
|
|
4151
4206
|
const generation = pauseGeneration.get(id);
|
|
4152
4207
|
const launch = peer.recoveryMetadata().launch as { route?: string; model: string };
|
|
4153
4208
|
try { await validateLocalChoice(launch.route, launch.model); }
|
|
@@ -4168,6 +4223,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4168
4223
|
|
|
4169
4224
|
const conductor = new Conductor(conductorHolds, {
|
|
4170
4225
|
roles: () => config.roles, capabilities: () => config.capabilities, instances: () => declarations,
|
|
4226
|
+
adapterCatalog,
|
|
4171
4227
|
status: () => {
|
|
4172
4228
|
const quota = budget.status();
|
|
4173
4229
|
const ids = [...new Set([...bus.knownPeers(), ...Object.keys(quota), ...conductorHolds.list().map(h => h.peer)])];
|
|
@@ -4200,7 +4256,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4200
4256
|
pause: peer => { pauseGeneration.set(peer, (pauseGeneration.get(peer) ?? 0) + 1); bus.pause(peer); writeStatus(); },
|
|
4201
4257
|
validateRelease: async peer => {
|
|
4202
4258
|
const owner = bus.peers.get(peer);
|
|
4203
|
-
if (!(owner
|
|
4259
|
+
if (!isLocalPeer(owner)) return;
|
|
4204
4260
|
const generation = pauseGeneration.get(peer);
|
|
4205
4261
|
const launch = owner.recoveryMetadata().launch as { route?: string; model: string };
|
|
4206
4262
|
await validateLocalChoice(launch.route, launch.model);
|
|
@@ -4375,12 +4431,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4375
4431
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4376
4432
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4377
4433
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4378
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer =>
|
|
4434
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
4379
4435
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4380
4436
|
}
|
|
4381
4437
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
4382
|
-
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
|
|
4383
|
-
validateEffectiveConfig(fileConfig);
|
|
4438
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
4439
|
+
validateEffectiveConfig(fileConfig, adapterCatalog);
|
|
4384
4440
|
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
4385
4441
|
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
4386
4442
|
const previews: { key: string; before: string; after: string; effect?: string }[] = [];
|
|
@@ -4389,7 +4445,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4389
4445
|
const prefix = layer.name.endsWith(".toml") ? "routing" : "";
|
|
4390
4446
|
for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
|
|
4391
4447
|
}
|
|
4392
|
-
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
|
|
4448
|
+
if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)])) paths.add(change.key);
|
|
4393
4449
|
for (const key of paths) {
|
|
4394
4450
|
if (scope && key !== scope) continue;
|
|
4395
4451
|
const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
|
|
@@ -4404,10 +4460,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4404
4460
|
const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
|
|
4405
4461
|
const rawAfter = current ? valueAt(current.raw, path) : undefined;
|
|
4406
4462
|
if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
|
|
4463
|
+
// Compare the running declaration, never a pending desired value accepted by an earlier reload.
|
|
4407
4464
|
// Instance declarations are restart-bound. Their derived role/permission/start defaults cannot leak into
|
|
4408
4465
|
// the live catalog while the old identities, paths and account callbacks still own the daemon.
|
|
4409
4466
|
const boundId = ["roles", "permission_modes", "peers", "permission_default_sources"].includes(path[0] ?? "") ? path[1] : undefined;
|
|
4410
|
-
|
|
4467
|
+
const boundKind = boundId ? declarations[boundId]?.kind ?? boundId : undefined;
|
|
4468
|
+
if (!route && boundId && (JSON.stringify(config.instances?.[boundId]) !== JSON.stringify(fileConfig.instances?.[boundId]) || JSON.stringify(boundKind ? config.adapters?.[boundKind] : undefined) !== JSON.stringify(boundKind ? fileConfig.adapters?.[boundKind] : undefined))) {
|
|
4411
4469
|
rejected.push({ key, reason: "instance declaration changed; its derived defaults apply together after daemon restart" });
|
|
4412
4470
|
continue;
|
|
4413
4471
|
}
|
|
@@ -4471,11 +4529,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4471
4529
|
}
|
|
4472
4530
|
candidate.retired = fileConfig.retired;
|
|
4473
4531
|
routing.retired = sources.routing.retired;
|
|
4474
|
-
const changes = configChanges({ ...config, roles: policyRoles }, candidate);
|
|
4532
|
+
const changes = configChanges({ ...config, roles: policyRoles }, candidate, [{ catalog: adapterCatalog, instances: declarations }, configIdentityScope(acceptedSources.identity), configIdentityScope(sources.identity)]);
|
|
4475
4533
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4476
4534
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4477
4535
|
running.retired = fileConfig.retired;
|
|
4478
|
-
validateEffectiveConfig(running);
|
|
4536
|
+
validateEffectiveConfig(running, adapterCatalog);
|
|
4479
4537
|
conductorPeer(running.roles);
|
|
4480
4538
|
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
4481
4539
|
const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
|
|
@@ -4505,7 +4563,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4505
4563
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
4506
4564
|
texts["config.json"] = next["config.json"];
|
|
4507
4565
|
texts["config.local.json"] = next["config.local.json"];
|
|
4508
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4566
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4509
4567
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4510
4568
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4511
4569
|
return prepared.fileConfig;
|
|
@@ -4513,7 +4571,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4513
4571
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
4514
4572
|
const texts = configSourceTexts(opts.cwd);
|
|
4515
4573
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
4516
|
-
const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
|
|
4574
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4517
4575
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4518
4576
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4519
4577
|
}
|
|
@@ -4530,7 +4588,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4530
4588
|
const texts = configSourceTexts(opts.cwd);
|
|
4531
4589
|
fingerprint = configFingerprint(texts);
|
|
4532
4590
|
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" };
|
|
4533
|
-
const sources = parseConfigSources(texts);
|
|
4591
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
4534
4592
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
4535
4593
|
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
4536
4594
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -4564,7 +4622,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4564
4622
|
desiredRouting = structuredClone(prepared.routing);
|
|
4565
4623
|
acknowledgeConfiguration(sources, prepared.accepted);
|
|
4566
4624
|
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4567
|
-
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4625
|
+
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! }));
|
|
4568
4626
|
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4569
4627
|
retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
|
|
4570
4628
|
storedConfigCache = undefined;
|
|
@@ -4597,7 +4655,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4597
4655
|
const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
|
|
4598
4656
|
if (storedConfigCache?.stamp !== stamp) {
|
|
4599
4657
|
let loaded: HubConfig | undefined;
|
|
4600
|
-
try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
|
|
4658
|
+
try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
|
|
4601
4659
|
storedConfigCache = { stamp, config: loaded };
|
|
4602
4660
|
}
|
|
4603
4661
|
return storedConfigCache.config;
|
|
@@ -4618,9 +4676,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4618
4676
|
let routingProblem: string | undefined;
|
|
4619
4677
|
try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
|
|
4620
4678
|
const rows = settingRows({
|
|
4621
|
-
instances: declarations, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4679
|
+
instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4622
4680
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4623
|
-
next: stored ? (path) => path[0] === "roster" ?
|
|
4681
|
+
next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
|
|
4624
4682
|
roles: roleState,
|
|
4625
4683
|
mode: (peer) => {
|
|
4626
4684
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4659,24 +4717,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4659
4717
|
if (owner?.state === "busy" && !now) return `${peer} is in a turn; retry when it is idle, or pass --now`;
|
|
4660
4718
|
return undefined;
|
|
4661
4719
|
}
|
|
4662
|
-
function codexResumeCommand(peer
|
|
4663
|
-
const owner = bus.peers.get(peer), thread = owner
|
|
4720
|
+
function codexResumeCommand(peer: string): string {
|
|
4721
|
+
const owner = bus.peers.get(peer), thread = isCodexPeer(owner) ? owner.thread || lastCodexThreads.get(peer) : lastCodexThreads.get(peer);
|
|
4664
4722
|
const command = declarations[peer] ? `ahub codex --as ${peer} resume` : "ahub codex resume";
|
|
4665
4723
|
return thread && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(thread) ? `${command} ${thread}` : command;
|
|
4666
4724
|
}
|
|
4667
4725
|
function retainedTerminalNotice(peer: string): string | undefined {
|
|
4668
4726
|
const owner = bus.peers.get(peer);
|
|
4669
|
-
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, owner
|
|
4727
|
+
return definitionOf(peer)?.cli.retainedTerminalNotice?.(peer, isPiPeer(owner) ? owner.mode : undefined, codexResumeCommand(peer));
|
|
4670
4728
|
}
|
|
4671
4729
|
|
|
4672
4730
|
async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
|
|
4673
4731
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
4674
4732
|
const peer = def.path[1]!;
|
|
4675
4733
|
let saved = false;
|
|
4734
|
+
// #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
|
|
4735
|
+
const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
|
|
4676
4736
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4677
4737
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4678
4738
|
try {
|
|
4679
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after =
|
|
4739
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4680
4740
|
if (!after) {
|
|
4681
4741
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4682
4742
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4690,14 +4750,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4690
4750
|
config.roster = next;
|
|
4691
4751
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
4692
4752
|
saved = true;
|
|
4693
|
-
recordSetting(def.key, before, after, source, undo, reason);
|
|
4753
|
+
recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
|
|
4694
4754
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
4695
4755
|
if (!after) {
|
|
4696
4756
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
4697
4757
|
const owner = bus.peers.get(peer);
|
|
4698
|
-
if (owner
|
|
4699
|
-
else if (owner
|
|
4700
|
-
else if (owner
|
|
4758
|
+
if (isWsPeer(owner)) { owner.detachForRoster(); if (definitionOf(peer)?.attach?.admission) clearKimiAdmission(); }
|
|
4759
|
+
else if (isCodexPeer(owner)) owner.detachForRoster(config.permission_modes[peer] === "never-ask" ? "ask" : config.permission_modes[peer] ?? "ask");
|
|
4760
|
+
else if (isPiPeer(owner) && owner.mode === "tui") await owner.detachForRoster();
|
|
4701
4761
|
else if (owner && owner.state !== "offline") {
|
|
4702
4762
|
const result = await stopPeer(peer, true);
|
|
4703
4763
|
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`);
|
|
@@ -4710,11 +4770,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4710
4770
|
handoff = await tasks.reassignForRoster(peer);
|
|
4711
4771
|
} else {
|
|
4712
4772
|
const owner = bus.peers.get(peer);
|
|
4713
|
-
if (owner
|
|
4773
|
+
if (isPiPeer(owner) && owner.mode === "tui") await owner.resumeFromRoster();
|
|
4714
4774
|
}
|
|
4715
4775
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4716
4776
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
4717
4777
|
writeStatus();
|
|
4778
|
+
if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
|
|
4718
4779
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
4719
4780
|
return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
|
|
4720
4781
|
} catch (error) {
|
|
@@ -4727,6 +4788,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4727
4788
|
const def = settingDef(key);
|
|
4728
4789
|
if (typeof def === "string") return { error: def };
|
|
4729
4790
|
const routing = activeRouting();
|
|
4791
|
+
if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
|
|
4792
|
+
// A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
|
|
4793
|
+
if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
|
|
4794
|
+
// Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
|
|
4795
|
+
const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
|
|
4796
|
+
if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
|
|
4730
4797
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
|
|
4731
4798
|
catch (error) { return { error: (error as Error).message }; }
|
|
4732
4799
|
}
|
|
@@ -4823,16 +4890,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4823
4890
|
const input = settingInput(key, raw);
|
|
4824
4891
|
if ("error" in input) return { ok: false, error: input.error };
|
|
4825
4892
|
const { def, value } = input;
|
|
4893
|
+
if (def.group === "Roster" && staleRosterKey(def.key)) {
|
|
4894
|
+
return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
|
|
4895
|
+
}
|
|
4826
4896
|
if (def.group === "Roster") {
|
|
4827
4897
|
try {
|
|
4828
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on =
|
|
4898
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4829
4899
|
return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
|
|
4830
4900
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4831
4901
|
}
|
|
4832
4902
|
if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
|
|
4833
4903
|
if (def.group === "Permissions") {
|
|
4834
4904
|
const peer = def.path.at(-1)!;
|
|
4835
|
-
return { ok: true, lines: [permissionBoundary(peer, declarations), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
4905
|
+
return { ok: true, lines: [permissionBoundary(peer, declarations, adapterCatalog), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value, declarations, adapterCatalog)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
|
|
4836
4906
|
}
|
|
4837
4907
|
if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
|
|
4838
4908
|
try {
|
|
@@ -4927,7 +4997,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4927
4997
|
// #269: the hub's own start of a peer from the closed list, in the mode a person set. No argument reaches a command.
|
|
4928
4998
|
if (!peer()) return bad;
|
|
4929
4999
|
const result = await hubStart(a.peer, "the dashboard");
|
|
4930
|
-
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)
|
|
5000
|
+
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)}` };
|
|
4931
5001
|
// A refusal the hub worded itself carries the command to run by hand; an adapter's own failure text stays in the log.
|
|
4932
5002
|
if (typeof result.command === "string" || typeof a.peer === "string" && !membershipAllowed(a.peer)) return { ok: false, error: sanitize(String(result.error)) };
|
|
4933
5003
|
log(`dashboard start ${a.peer} failed: ${String(result.error)}`);
|
|
@@ -4963,7 +5033,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4963
5033
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
4964
5034
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
4965
5035
|
...(releasing ? ["ready tasks being released"] : []),
|
|
4966
|
-
...([...bus.peers].filter(([, peer]) => peer
|
|
5036
|
+
...([...bus.peers].filter(([, peer]) => isPiPeer(peer) && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && isPiPeer(peer) && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
|
|
4967
5037
|
],
|
|
4968
5038
|
integrity: { current: integrityAs(restored?.integrity), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
|
|
4969
5039
|
peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
|
|
@@ -4984,6 +5054,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4984
5054
|
const incompatibleRecoveryTarget = () => {
|
|
4985
5055
|
const cancelled = incompatibleCancellationTarget();
|
|
4986
5056
|
if (cancelled) return cancelled;
|
|
5057
|
+
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";
|
|
5058
|
+
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
4987
5059
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
4988
5060
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
4989
5061
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
@@ -4995,7 +5067,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4995
5067
|
}
|
|
4996
5068
|
|
|
4997
5069
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4998
|
-
for (const pi of bus.peers.values()) if (pi
|
|
5070
|
+
for (const pi of bus.peers.values()) if (isPiPeer(pi) && pi.state === "idle") {
|
|
4999
5071
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
5000
5072
|
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
5001
5073
|
}
|
|
@@ -5105,7 +5177,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5105
5177
|
return recoveryError("unknown recovery operation");
|
|
5106
5178
|
}
|
|
5107
5179
|
|
|
5108
|
-
function claudePermissionLaunch(peer
|
|
5180
|
+
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5109
5181
|
try {
|
|
5110
5182
|
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
5111
5183
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
@@ -5114,15 +5186,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5114
5186
|
}
|
|
5115
5187
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5116
5188
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
5189
|
+
if (readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
5117
5190
|
if (definitionOf(peer)?.permission.unmanaged) return { ok: false, error: definitionOf(peer)!.permission.unmanaged };
|
|
5118
|
-
if (definitionOf(peer)?.permission.unmanagedForms?.includes("tui") && (bus.peers.get(peer)
|
|
5191
|
+
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 };
|
|
5119
5192
|
if (requestedPeerStops.has(peer)) return { ok: false, error: "peer stop is in progress; wait for ahub status before changing its mode" };
|
|
5120
5193
|
const owner = bus.peers.get(peer);
|
|
5121
5194
|
if (mode === "ask" && (!owner || owner.state === "offline")) {
|
|
5122
5195
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
5123
5196
|
// to it would run a mode the status no longer shows.
|
|
5124
5197
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
5125
|
-
const note = owner
|
|
5198
|
+
const note = isCodexPeer(owner) && !owner.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;
|
|
5126
5199
|
if (note) log(`permission mode codex: ${note}`);
|
|
5127
5200
|
const from = permissionMode(peer);
|
|
5128
5201
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -5130,22 +5203,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5130
5203
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
5131
5204
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
5132
5205
|
}
|
|
5133
|
-
if (owner
|
|
5206
|
+
if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5134
5207
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
5135
5208
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
5136
5209
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
5137
5210
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
5138
5211
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
5139
5212
|
const launch = claudePermissionLaunch(peer);
|
|
5140
|
-
if (!launch || !(owner
|
|
5213
|
+
if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
5141
5214
|
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" };
|
|
5142
|
-
} else if (owner
|
|
5215
|
+
} else if (isCodexPeer(owner)) {
|
|
5143
5216
|
if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5144
5217
|
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" };
|
|
5145
5218
|
}
|
|
5146
5219
|
const from = permissionMode(peer);
|
|
5220
|
+
const runtime: PeerAdapter = owner;
|
|
5221
|
+
const nativeMode = runtime.permissionModeState;
|
|
5222
|
+
if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
|
|
5147
5223
|
try {
|
|
5148
|
-
if (
|
|
5224
|
+
if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
|
|
5149
5225
|
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
5150
5226
|
if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
|
|
5151
5227
|
permissionModes.set(peer, mode);
|
|
@@ -5158,6 +5234,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5158
5234
|
}
|
|
5159
5235
|
|
|
5160
5236
|
async function confirmPermissionDefault(peer: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5237
|
+
if (typeof peer === "string" && readOnlyPeer(peer)) return { ok: false, error: "read-only ceiling is locked for this instance" };
|
|
5161
5238
|
if (typeof peer !== "string" || !pendingPermissionDefaults.has(peer)) return { ok: false, error: "no pending permission default for this peer" };
|
|
5162
5239
|
const source = pendingPermissionDefaults.get(peer)!;
|
|
5163
5240
|
if (confirmed !== true) {
|
|
@@ -5167,7 +5244,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5167
5244
|
}
|
|
5168
5245
|
const owner = bus.peers.get(peer);
|
|
5169
5246
|
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" };
|
|
5170
|
-
if (!starting.has(peer) && owner
|
|
5247
|
+
if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
5171
5248
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
5172
5249
|
const result = await changePermission(peer, "never-ask", true);
|
|
5173
5250
|
if (!result.ok) return result;
|
|
@@ -5198,9 +5275,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5198
5275
|
const result = await permissionChange(peer, async () => {
|
|
5199
5276
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
5200
5277
|
const selected = permissionMode(peer);
|
|
5201
|
-
if (owner
|
|
5202
|
-
if (!(
|
|
5203
|
-
if (owner
|
|
5278
|
+
if (isCodexPeer(owner) && 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" };
|
|
5279
|
+
if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
|
|
5280
|
+
if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
5204
5281
|
else await owner.setPermissionMode(selected);
|
|
5205
5282
|
}
|
|
5206
5283
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -5237,7 +5314,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5237
5314
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5238
5315
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5239
5316
|
} else {
|
|
5240
|
-
c.peer = String(msg.peer ??
|
|
5317
|
+
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5241
5318
|
if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
|
|
5242
5319
|
const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
|
|
5243
5320
|
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");
|
|
@@ -5253,9 +5330,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5253
5330
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
5254
5331
|
}
|
|
5255
5332
|
let peer = bus.peers.get(c.peer);
|
|
5256
|
-
if (kimiPull && peer
|
|
5333
|
+
if (kimiPull && isAcpPeer(peer) && peer.state === "offline") peer = undefined;
|
|
5257
5334
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
5258
|
-
if (!(peer
|
|
5335
|
+
if (!isWsPeer(peer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
5259
5336
|
const ws = peer;
|
|
5260
5337
|
if (msg.standby === true && ws.standbyHeld(msg.channel === false)) return sock.close(4000, "another session holds or is claiming this peer");
|
|
5261
5338
|
// Without pushes it stands by, as a replaced session does, and never strands a channel session's deliveries.
|
|
@@ -5321,7 +5398,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5321
5398
|
}
|
|
5322
5399
|
case "delivery_receipt": {
|
|
5323
5400
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5324
|
-
if (c.role !== "peer" || !(peer
|
|
5401
|
+
if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
|
|
5325
5402
|
if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
|
|
5326
5403
|
const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
|
|
5327
5404
|
bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
|
|
@@ -5331,7 +5408,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5331
5408
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5332
5409
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5333
5410
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5334
|
-
if (c.role !== "peer" || !(peer
|
|
5411
|
+
if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
5335
5412
|
try {
|
|
5336
5413
|
const envs = bus.pull(peer.id);
|
|
5337
5414
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
@@ -5343,7 +5420,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5343
5420
|
}
|
|
5344
5421
|
case "delivery_complete": {
|
|
5345
5422
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
5346
|
-
if (c.role !== "peer" || !(peer
|
|
5423
|
+
if (c.role !== "peer" || !isWsPeer(peer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId)) return void reply({ ok: false, error: "delivery does not belong to this connection generation" });
|
|
5347
5424
|
return void reply(bus.completeDelivery(peer.id, msg.deliveryId) ? { ok: true } : { ok: false, error: "delivery is not live and accepted; inspect ahub queue show before resolving" });
|
|
5348
5425
|
}
|
|
5349
5426
|
case "execution_budget": {
|
|
@@ -5419,7 +5496,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5419
5496
|
if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
|
|
5420
5497
|
launch.session = native.sessionId;
|
|
5421
5498
|
const owner = bus.peers.get(peer);
|
|
5422
|
-
if (!(owner
|
|
5499
|
+
if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
|
|
5423
5500
|
if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
|
|
5424
5501
|
launch.generation = owner.sessionGeneration;
|
|
5425
5502
|
const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
|
|
@@ -5517,15 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5517
5594
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5518
5595
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5519
5596
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
5520
|
-
if (permission !== undefined && nativePeer
|
|
5597
|
+
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5521
5598
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5522
5599
|
const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
|
|
5523
5600
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5524
5601
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5525
|
-
const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles
|
|
5602
|
+
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")));
|
|
5526
5603
|
if (nativeClaude && !observesNative) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
5527
5604
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
5528
|
-
if ((phase === "start" || phase === "pre") && nativePeer
|
|
5605
|
+
if ((phase === "start" || phase === "pre") && isWsPeer(nativePeer)) {
|
|
5529
5606
|
const previous = nativeTurnStart.get(peer);
|
|
5530
5607
|
const current = claudeSession(peer);
|
|
5531
5608
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
@@ -5548,7 +5625,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5548
5625
|
const st = hookStats.get(peer)!;
|
|
5549
5626
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
5550
5627
|
hookStats.delete(peer);
|
|
5551
|
-
if (id && nativePeer
|
|
5628
|
+
if (id && isWsPeer(nativePeer)) nativePeer.nativeStopped(sessionId!);
|
|
5552
5629
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
5553
5630
|
};
|
|
5554
5631
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
@@ -5557,13 +5634,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5557
5634
|
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
5558
5635
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
5559
5636
|
const started = nativeTurnStart.get(peer);
|
|
5560
|
-
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer
|
|
5637
|
+
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && isWsPeer(nativePeer) && started.generation === nativePeer.sessionGeneration;
|
|
5561
5638
|
// A seen previous completion is a duplicate only outside an active start. During B it is B's baseline.
|
|
5562
5639
|
if (!activeStart) {
|
|
5563
5640
|
const latest = readCompletion();
|
|
5564
5641
|
if (latest?.at && nativeStopsSeen.has(completionId(latest.id))) return void reply({ t: "facts", ok: true });
|
|
5565
5642
|
}
|
|
5566
|
-
if (!started || !activeStart || !(nativePeer
|
|
5643
|
+
if (!started || !activeStart || !isWsPeer(nativePeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
5567
5644
|
const scopeCurrent = () => {
|
|
5568
5645
|
const now = claudeSession(peer);
|
|
5569
5646
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
@@ -5644,7 +5721,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5644
5721
|
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
5645
5722
|
const owner = bus.peers.get(peer);
|
|
5646
5723
|
const admission = definitionOf(peer)?.attach?.admission;
|
|
5647
|
-
if (admission && !(owner
|
|
5724
|
+
if (admission && !isAcpPeer(owner) && (isWsPeer(owner) || (kimiSessionMode ?? startMode(peer)) === "tui")) return void reply({ ok: false, error: admission.watchRefusal });
|
|
5648
5725
|
if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
|
|
5649
5726
|
c.watch?.();
|
|
5650
5727
|
c.watch = activityRings[peer]!.subscribe(item => {
|
|
@@ -5682,6 +5759,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5682
5759
|
if (msg.op === "export") {
|
|
5683
5760
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5684
5761
|
}
|
|
5762
|
+
if (msg.op === "accept-departure") {
|
|
5763
|
+
void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
|
|
5764
|
+
() => reply({ ok: true }),
|
|
5765
|
+
() => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
|
|
5766
|
+
);
|
|
5767
|
+
return;
|
|
5768
|
+
}
|
|
5685
5769
|
if (msg.op === "accept") {
|
|
5686
5770
|
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5687
5771
|
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
|
@@ -5767,7 +5851,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5767
5851
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
5768
5852
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
5769
5853
|
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
|
|
5770
|
-
peer !== definitionOf(peer)?.kind || !(owner
|
|
5854
|
+
peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
|
|
5771
5855
|
try {
|
|
5772
5856
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
5773
5857
|
const options = msg.args ?? {};
|