@staix/agent-hub 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +12 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +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,10 @@
|
|
|
1
|
+
import { GeneratedNoteRegistry, type GeneratedNoteProof } from "../memory/record-provenance.ts";
|
|
2
|
+
import { RecordCapture, type RecordCapturePolicy } from "../memory/records.ts";
|
|
3
|
+
import { VisualStore, validateVisualInput, validateVisualPayload, VISUAL_ID_PATTERN, type VisualRecord, type VisualSummary } from "./visual.ts";
|
|
4
|
+
import { resolveVisualAssets } from "./visual-assets.ts";
|
|
5
|
+
import type { DeliverablePorts } from "./deliverable-files.ts";
|
|
6
|
+
import { readStartupEventSeeds } from "./startup-event-seeds.ts";
|
|
7
|
+
import { StartupTimings } from "./startup-timing.ts";
|
|
1
8
|
import { repoGitArgs, repoGitEnv } from "./snapshots.ts";
|
|
2
9
|
import { createReviewMaterialReader, type ReviewReadRequest, type ReviewMaterialBatch } from "./review-material.ts";
|
|
3
10
|
import { readSdlcPr } from "./sdlc.ts";
|
|
@@ -35,7 +42,7 @@ import type { DocEvidence } from "./sdlc-release.ts";
|
|
|
35
42
|
import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, externalModuleIdentity, externalModuleIdentityCurrent, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
36
43
|
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
37
44
|
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
38
|
-
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
45
|
+
import { NativeHookLaunches, type NativeHookLaunch } from "./native-hook-launch.ts";
|
|
39
46
|
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
40
47
|
import { ContainerRunner, RemoteRunner, remoteRunnerConfig, type RemoteRunnerConfig, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
41
48
|
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
@@ -56,7 +63,7 @@ import { listenWithoutPortCollision } from "./listen.ts";
|
|
|
56
63
|
import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
57
64
|
import { loadPetConfig, PET_WINDOW_RETIRED_NOTICE, stripLegacyPetWindow } from "../pet/config.ts";
|
|
58
65
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
59
|
-
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
66
|
+
import { policyPaths, policyRealPath, matchesPolicy } from "./path-policy.ts";
|
|
60
67
|
import { CodeFacts, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
|
|
61
68
|
import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
|
|
62
69
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
@@ -75,6 +82,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
|
75
82
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
76
83
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
|
|
77
84
|
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";
|
|
85
|
+
import type { NativeFactsTranscript } from "../adapters/registry-types.ts";
|
|
78
86
|
import type { ReviewScope } from "./review-scope.ts";
|
|
79
87
|
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, isPlannerOnly, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
80
88
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, withoutInertInstancePolicy, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
@@ -105,7 +113,7 @@ import { QuestionDesk } from "./question-desk.ts";
|
|
|
105
113
|
import type { ScannedFile } from "./schedules.ts";
|
|
106
114
|
import { corpusConfig } from "./corpus-config.ts";
|
|
107
115
|
import { ALL_MACHINE_LOCAL, configRefusal, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
|
|
108
|
-
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
|
|
116
|
+
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateRawConfig, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
|
|
109
117
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
110
118
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
|
|
111
119
|
import { issueConductorRecoveryAuthority, authorizeConductorRecovery, assertConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "./recovery-authority.ts";
|
|
@@ -116,9 +124,10 @@ import { planFingerprint, registeredProjects, type UpgradePlan } from "../cli/up
|
|
|
116
124
|
import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
|
|
117
125
|
import { createConfigEditor } from "./config-edit-host.ts";
|
|
118
126
|
import { readEditable } from "./config-edit.ts";
|
|
119
|
-
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
127
|
+
import { eventLog, readEvents, type HubEvent, type RoleIdentity } from "./events.ts";
|
|
120
128
|
import { SpecTaskInspector } from "./specs-task.ts";
|
|
121
|
-
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
129
|
+
import { KimiUsage, kimiSeedRows, type KimiRowAttribution, type KimiUsageScheduler } from "./kimi-usage.ts";
|
|
130
|
+
import { KimiEventWriter } from "./kimi-events.ts";
|
|
122
131
|
import { TokenWindow } from "./token-window.ts";
|
|
123
132
|
import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
|
|
124
133
|
import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
|
|
@@ -133,6 +142,8 @@ import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
|
133
142
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
134
143
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
135
144
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
145
|
+
import { ModelOverrides, ModelOverrideLatches, type RuntimeModelStatus, type ModelSource } from "./model-overrides.ts";
|
|
146
|
+
import { isNativeModelToken } from "../adapters/model-token.ts";
|
|
136
147
|
import { processSignature, processLiveness } from "../pi/process-signature.ts";
|
|
137
148
|
import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
|
|
138
149
|
import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
|
|
@@ -156,7 +167,7 @@ import { Capture } from "../memory/capture.ts";
|
|
|
156
167
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
157
168
|
import { CorpusIndex } from "./corpus.ts";
|
|
158
169
|
import { corpusEvidence } from "./corpus-evidence.ts";
|
|
159
|
-
import { Briefs, type BriefTask } from "../memory/brief.ts";
|
|
170
|
+
import { Briefs, preferRecordItems, type NativeItem, type BriefTask } from "../memory/brief.ts";
|
|
160
171
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
161
172
|
import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
|
|
162
173
|
import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
@@ -200,12 +211,14 @@ import { ControlClient, DAEMON_FEATURES, HANDOFF_CONNECT_MS, HANDOFF_LOOKUP_CONN
|
|
|
200
211
|
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
201
212
|
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
202
213
|
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
203
|
-
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
214
|
+
import { MAX_HOP, isInvocationKind, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
204
215
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter, type PeerTakeoverClaim } from "./peers.ts";
|
|
205
216
|
import { ClaudeTodoSource, DoingTracker, readAiTitle, DOING_STUB, SESSION_TITLE_HINT, sessionTitleText, sessionTitleBindings, unnamedSessionLabel, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
|
|
206
217
|
import { MemoryClient } from "../memory/client.ts";
|
|
207
218
|
import { VERSION } from "../version.ts";
|
|
208
219
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
220
|
+
import { readEvidence } from "./research-record.ts";
|
|
221
|
+
import { memoryFigures, onlyTaskMemory, UNKNOWN_MEMORY_FIGURES, type MemoryFigures } from "./research-memory.ts";
|
|
209
222
|
import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
|
|
210
223
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
211
224
|
import { projectChain } from "@staix/agent-mem";
|
|
@@ -225,11 +238,11 @@ import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
|
|
|
225
238
|
import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
226
239
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
227
240
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
228
|
-
import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
|
|
241
|
+
import { isHubWritePath, grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
|
|
229
242
|
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
230
243
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
231
244
|
import { detectTooling } from "./tooling.ts";
|
|
232
|
-
import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
245
|
+
import { CaptureStore, configuredPolicy, recordsEnabled, looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
233
246
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
234
247
|
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
235
248
|
import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
|
|
@@ -237,7 +250,8 @@ import { SourceRegistry } from "./sources.ts";
|
|
|
237
250
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
|
|
238
251
|
import { nativeRecall } from "../memory/recall.ts";
|
|
239
252
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
240
|
-
import {
|
|
253
|
+
import { hostPlacementAttestation, memorySyncErrorText, writerRefusalOf, type WriterRefusal } from "./memory-sync-attestation.ts";
|
|
254
|
+
import { classifyMemorySyncFailure, initialMemorySyncState, memorySyncDeclinedDetails, memorySyncFailureDetailHash, memorySyncView, memorySyncNextStep, memorySyncPlacementCounts, reduceMemorySync } from "./memory-sync-state.ts";
|
|
241
255
|
import { createMemorySyncPolicy, inspectDeclinedMemoryRows } from "./memory-sync-policy.ts";
|
|
242
256
|
import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
|
|
243
257
|
|
|
@@ -307,6 +321,8 @@ export interface HubConfig {
|
|
|
307
321
|
checks: { timeout_s: number; runner?: "container" | "remote"; remote?: RemoteRunnerConfig; [cls: string]: string | number | RemoteRunnerConfig | undefined };
|
|
308
322
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
309
323
|
snapshots: { enabled: boolean; keep: number };
|
|
324
|
+
/** Unbound visuals expire; task-bound records retain the task lifetime. */
|
|
325
|
+
visuals: { retention_days: number };
|
|
310
326
|
/** #1057: the worktree shelf. `quiet_days` shelves a worktree idle that long (0 = never); `alert_over` is the settled count that raises one inbox alert (0 = never). */
|
|
311
327
|
worktrees: { quiet_days: number; alert_over: number };
|
|
312
328
|
/** #1053: `ttl_s` expires an unanswered question (default 86400); `wait_ms` overrides an adapter definition's `questionWaitMs` per kind. */
|
|
@@ -385,6 +401,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
385
401
|
snapshots: { enabled: false, keep: 20 },
|
|
386
402
|
worktrees: { quiet_days: 7, alert_over: 10 },
|
|
387
403
|
questions: { ttl_s: 86_400, wait_ms: {} },
|
|
404
|
+
visuals: { retention_days: 30 },
|
|
388
405
|
research: { enabled: false },
|
|
389
406
|
bench: { enabled: false },
|
|
390
407
|
peers: {},
|
|
@@ -400,14 +417,14 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
400
417
|
export { stateDirFor };
|
|
401
418
|
|
|
402
419
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
403
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
|
|
420
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_visual_show", "hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
|
|
404
421
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
405
422
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
406
423
|
|
|
407
424
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
408
425
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
409
426
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
410
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "worktrees", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet", "questions"];
|
|
427
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "worktrees", "visuals", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet", "questions"];
|
|
411
428
|
|
|
412
429
|
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
413
430
|
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
@@ -424,6 +441,18 @@ function rawResearchRunnersPresent(cwd: string): boolean {
|
|
|
424
441
|
return present;
|
|
425
442
|
}
|
|
426
443
|
|
|
444
|
+
/** Raw declaration presence is the floor: defaults in the in-memory config are not an opt-in. */
|
|
445
|
+
function rawVisualsPresent(cwd: string): boolean {
|
|
446
|
+
const texts = configSourceTexts(cwd);
|
|
447
|
+
for (const name of CONFIG_FILES) {
|
|
448
|
+
if (texts[name] === null) continue;
|
|
449
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
450
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
451
|
+
if (Object.hasOwn(raw, "visuals")) return true;
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
|
|
427
456
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
428
457
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
429
458
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
@@ -650,6 +679,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
650
679
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
651
680
|
worktrees: { quiet_days: wholeDays(file.worktrees?.quiet_days, DEFAULT_CONFIG.worktrees.quiet_days), alert_over: wholeDays(file.worktrees?.alert_over, DEFAULT_CONFIG.worktrees.alert_over) },
|
|
652
681
|
questions: questionsConfig(file.questions),
|
|
682
|
+
visuals: { retention_days: typeof file.visuals?.retention_days === "number" && Number.isSafeInteger(file.visuals.retention_days) && file.visuals.retention_days >= 1 && file.visuals.retention_days <= 3650 ? file.visuals.retention_days : DEFAULT_CONFIG.visuals.retention_days },
|
|
653
683
|
research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
|
|
654
684
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
655
685
|
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
@@ -686,6 +716,10 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
686
716
|
}
|
|
687
717
|
|
|
688
718
|
export interface DaemonOptions {
|
|
719
|
+
/** Host-only fixture override; production reads the active memory home's and execution repository's public Record policy. */
|
|
720
|
+
effectiveRecordPolicy?: (cwd: string) => { enabled: boolean; version: string } | undefined;
|
|
721
|
+
/** Host-only numeric startup reader observation; never read from config or control input. Observer failure changes no replay state. */
|
|
722
|
+
startupSeedObserved?: (observation: { readPasses: number; readBytes: number }) => void;
|
|
689
723
|
/** Host-only test/embedding port; never read from project configuration or a tool. */
|
|
690
724
|
memoryClient?: MemoryClient;
|
|
691
725
|
/** Host-only remote-default evidence seam; never read from config or control messages. */
|
|
@@ -710,6 +744,8 @@ export interface DaemonOptions {
|
|
|
710
744
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
711
745
|
/** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
|
|
712
746
|
kimiHome?: string;
|
|
747
|
+
/** Host-only delayed-drain scheduler; tests fire it explicitly. Production leaves it unset and uses unreferenced timers. */
|
|
748
|
+
kimiUsageScheduler?: KimiUsageScheduler;
|
|
713
749
|
/** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
|
|
714
750
|
toolWriteReplayMaxEntries?: number;
|
|
715
751
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
@@ -863,13 +899,16 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
863
899
|
}
|
|
864
900
|
|
|
865
901
|
export async function startDaemon(opts: DaemonOptions) {
|
|
902
|
+
const startupTimings = new StartupTimings();
|
|
866
903
|
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
867
904
|
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
905
|
+
startupTimings.mark("schema");
|
|
868
906
|
if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
|
|
869
907
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
870
908
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
871
909
|
const projectId = opts.projectId ?? randomUUID();
|
|
872
910
|
const sourcePackageDigest = capturedRecoverySourceDigest();
|
|
911
|
+
startupTimings.mark("source");
|
|
873
912
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
874
913
|
const startupCleanup: (() => void | Promise<void>)[] = [];
|
|
875
914
|
let ready = false;
|
|
@@ -1121,6 +1160,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1121
1160
|
let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
|
|
1122
1161
|
for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");view??=publicOperationalView();publicLine??=view.line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine,complete:view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:{})}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
|
|
1123
1162
|
};
|
|
1163
|
+
startupTimings.mark("configuration");
|
|
1124
1164
|
let runningRoutingDefault = recoveryDefaultRouting;
|
|
1125
1165
|
if (runningRoutingDefault === undefined) try { runningRoutingDefault = recoveryRoutingFallback(); } catch { /* unknown defaults refuse conductor recovery */ }
|
|
1126
1166
|
let runningRouting = recoveryRouting ?? currentRouting(opts.cwd, log);
|
|
@@ -1242,12 +1282,42 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1242
1282
|
// first writes once its peers have stopped and their turns ended.
|
|
1243
1283
|
const researchGraceMs = opts.researchGraceMs ?? 10_000;
|
|
1244
1284
|
const researchPending = new Map<number, { since: number; timer: ReturnType<typeof setTimeout> }>();
|
|
1285
|
+
/**
|
|
1286
|
+
* #1279: the counter figures of one task, read from agent-mem's sidecar at the moment the record is written (the sidecar, the store and the memory project are bound further down, and exist by
|
|
1287
|
+
* then). Undefined when memory or the sidecar is off, so the record's counters stay unknown; a task that is private reports none, whatever the sidecar holds.
|
|
1288
|
+
*/
|
|
1289
|
+
const memoryFiguresFor = (taskId: number): MemoryFigures => {
|
|
1290
|
+
const row = board.get(taskId);
|
|
1291
|
+
if (!candidates || !memoryStore || !row || tasks.isPii(row)) return { ...UNKNOWN_MEMORY_FIGURES };
|
|
1292
|
+
try {
|
|
1293
|
+
const store = memoryStore, project = memoryProject;
|
|
1294
|
+
const planContext = { cwd: opts.cwd, project, repo: memoryRepo, deny: config.local.deny };
|
|
1295
|
+
const briefTask = { id: row.id, created: row.created, title: "", refs: { paths: row.refs.paths ?? [] }, plan: { paths: row.plan?.paths ?? [], symbols: row.plan?.symbols ?? [] } };
|
|
1296
|
+
// The store has no read by id (`get` and `list` each rebuild the whole live view), so it is listed at most once per record, and only when the task has a rendered counter at all: a task with
|
|
1297
|
+
// none never touches it. Only the rendered entries are screened, not the store. A hidden or unreadable entry is simply absent, and the figures then call their fail counts unknown.
|
|
1298
|
+
let listed: Map<string, ReturnType<MemoryStore["list"]>[number]> | undefined;
|
|
1299
|
+
const view = (id: string) => {
|
|
1300
|
+
listed ??= new Map(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map(v => [v.id, v]));
|
|
1301
|
+
const found = listed.get(id);
|
|
1302
|
+
return found ? screenMemory([found])[0] : undefined;
|
|
1303
|
+
};
|
|
1304
|
+
return memoryFigures({
|
|
1305
|
+
counters: candidates.counters(project),
|
|
1306
|
+
taskKey: taskKey(project, row),
|
|
1307
|
+
view,
|
|
1308
|
+
matchingPlan: fails => matchingPlanFails(briefTask, fails, planContext),
|
|
1309
|
+
});
|
|
1310
|
+
} catch { return { ...UNKNOWN_MEMORY_FIGURES }; }
|
|
1311
|
+
};
|
|
1245
1312
|
const writeResearch = (taskId: number) => {
|
|
1246
1313
|
const pending = researchPending.get(taskId);
|
|
1247
1314
|
if (pending) clearTimeout(pending.timer);
|
|
1248
1315
|
researchPending.delete(taskId);
|
|
1249
1316
|
try {
|
|
1250
|
-
|
|
1317
|
+
// #1277: the board and journal are read through a read-only handle, so the record carries its v2 blocks; the family lookup is the project's own (instances resolve there).
|
|
1318
|
+
// Scoped to the one task: the live writer runs on the daemon thread and must not parse every task's history and the whole journal for one record (a backfill reads them all).
|
|
1319
|
+
const evidence = { ...readEvidence(opts.stateDir, projectId, taskId), familyOf: (peer: string) => adapterCatalog.familyOf(peer, declarations), memory: onlyTaskMemory(taskId, memoryFiguresFor) };
|
|
1320
|
+
appendRecords(projectId, taskRecords(readEvents(join(opts.stateDir, "events.jsonl")), projectId, { version: VERSION, source: "live" }, evidence).filter((r) => r.task === taskId));
|
|
1251
1321
|
} catch (error) {
|
|
1252
1322
|
if (!researchLogged) log(`research record not written: ${(error as Error).message}`);
|
|
1253
1323
|
researchLogged = true;
|
|
@@ -1298,7 +1368,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1298
1368
|
sock.data.closed = true;
|
|
1299
1369
|
for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
|
|
1300
1370
|
sock.data.invocationRequests?.clear();
|
|
1301
|
-
nativeHookLaunches.endOwner(sock)
|
|
1371
|
+
for (const { peer } of nativeHookLaunches.endOwner(sock)) {
|
|
1372
|
+
const run = runs.get(peer);
|
|
1373
|
+
if (!run) continue;
|
|
1374
|
+
if (budget.record(peer)) completionStore.dropRun(run.runTurn);
|
|
1375
|
+
else completionDone(peer, run, "interrupted");
|
|
1376
|
+
runs.close(peer);
|
|
1377
|
+
}
|
|
1302
1378
|
if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
|
|
1303
1379
|
consoles.delete(sock);
|
|
1304
1380
|
operationalLogSubscribers.delete(sock);
|
|
@@ -1321,8 +1397,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1321
1397
|
}), opts.controlPort === 0 ? [opts.codexAppPort, opts.codexProxyPort] : []);
|
|
1322
1398
|
// Written once this run owns the port, before any connection can attach a peer: a daemon that fails to bind (another
|
|
1323
1399
|
// is running) never marks a run boundary in the live one's events.
|
|
1324
|
-
event({ type: "hub_start", instance: instanceId });
|
|
1400
|
+
event({ type: "hub_start", instance: instanceId, personAttribution: true });
|
|
1325
1401
|
startupCleanup.push(() => server.stop(true));
|
|
1402
|
+
startupTimings.mark("control");
|
|
1326
1403
|
|
|
1327
1404
|
|
|
1328
1405
|
const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
|
|
@@ -1374,12 +1451,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1374
1451
|
const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
|
|
1375
1452
|
const adapterTiming = adapterOperationalReaders(() => config);
|
|
1376
1453
|
const modelTiming = modelOperationalReaders(() => config);
|
|
1454
|
+
startupTimings.mark("recovery");
|
|
1377
1455
|
const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
|
|
1378
1456
|
// What the crash left in flight, per recipient: opening the journal just marked these needs_review.
|
|
1379
1457
|
const lost = new Map<PeerId, JournalDelivery[]>();
|
|
1380
1458
|
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) { lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]); crashLostIds.add(d.id); }
|
|
1381
1459
|
// Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
|
|
1382
1460
|
// A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
|
|
1461
|
+
startupTimings.mark("journal");
|
|
1383
1462
|
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
|
|
1384
1463
|
if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
|
|
1385
1464
|
const list = config.capabilities[peer];
|
|
@@ -1769,6 +1848,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1769
1848
|
}, delayMs);
|
|
1770
1849
|
recoveryLeaseTimer.unref?.();
|
|
1771
1850
|
};
|
|
1851
|
+
startupTimings.mark("bus");
|
|
1772
1852
|
const chain = projectChain(opts.cwd);
|
|
1773
1853
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
1774
1854
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
@@ -1862,7 +1942,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1862
1942
|
publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
|
|
1863
1943
|
capture: () => ({ memoryDir: captureBinding!.home }), log,
|
|
1864
1944
|
});
|
|
1945
|
+
// ponytail: generated-note proofs are bounded to this daemon lifetime; after restart, unknown notes remain untouched.
|
|
1946
|
+
const generatedRecordNotes = new GeneratedNoteRegistry();
|
|
1865
1947
|
const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
|
|
1948
|
+
startupTimings.mark("memory");
|
|
1866
1949
|
const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
|
|
1867
1950
|
const memoryLine = (v: MemoryView) => `mem:${v.id.slice(0, 8)} ${v.kind} ${v.tier}: ${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 160)}`;
|
|
1868
1951
|
/** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
|
|
@@ -2033,18 +2116,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2033
2116
|
/** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
|
|
2034
2117
|
function syncNow(): Promise<void> {
|
|
2035
2118
|
if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
|
|
2036
|
-
|
|
2037
|
-
|
|
2119
|
+
let attestation: ReturnType<typeof hostPlacementAttestation> | undefined;
|
|
2120
|
+
const recordFailure = (cause: ReturnType<typeof classifyMemorySyncFailure> & { declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false; writerCategory?: string; writerCauseClass?: string }, errorText?: string) => {
|
|
2121
|
+
if (cause.cause !== "writer" && quietSyncError(errorText)) {
|
|
2038
2122
|
const state = errorText?.startsWith("stopped") ? "stopped" : "not-configured";
|
|
2039
2123
|
memorySyncState = reduceMemorySync(memorySyncState, { type: "quiet", at: Date.now(), state }).state;
|
|
2040
2124
|
return;
|
|
2041
2125
|
}
|
|
2042
2126
|
const reduced = reduceMemorySync(memorySyncState, { type: "failure", at: Date.now(), ...cause });
|
|
2043
2127
|
memorySyncState = reduced.state;
|
|
2044
|
-
if (reduced.logFailure
|
|
2128
|
+
if (reduced.logFailure) log(`hub memory sync failed: ${cause.cause}${cause.cause === "declined" ? ` (${cause.declinedCount ?? 0} declined)` : cause.cause === "writer" ? ` (${cause.writerCategory ?? "unknown"}, ${cause.writerCauseClass ?? "unavailable"}); next: ${memorySyncNextStep("writer")}` : ""}`);
|
|
2045
2129
|
};
|
|
2130
|
+
/** #1173: a refused writer attestation is its own closed cause with its category and next step, never a silent skip or a generic unknown. */
|
|
2131
|
+
const writerFailure = (refusal: WriterRefusal) => ({ cause: "writer" as const, writerCategory: refusal.category, writerCauseClass: refusal.causeClass });
|
|
2046
2132
|
const recordUnexpectedFailure = (error: unknown) => {
|
|
2047
|
-
const
|
|
2133
|
+
const refusal = attestation?.refusal() ?? writerRefusalOf(error);
|
|
2134
|
+
if (refusal) { recordFailure(writerFailure(refusal), undefined); return; }
|
|
2135
|
+
const errorText = memorySyncErrorText(error);
|
|
2048
2136
|
if (quietSyncError(errorText)) {
|
|
2049
2137
|
recordFailure(classifyMemorySyncFailure(error), errorText);
|
|
2050
2138
|
return;
|
|
@@ -2056,11 +2144,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2056
2144
|
checkMemory();
|
|
2057
2145
|
const policy = currentMemorySyncPolicy();
|
|
2058
2146
|
const policyVersion = memorySyncPolicyVersion();
|
|
2147
|
+
// Fresh per pass: agent-mem re-measures every writer install on each check; the legacy transport ignores it.
|
|
2148
|
+
attestation = hostPlacementAttestation(memoryStore!.profile.home);
|
|
2059
2149
|
const r = await (opts.memorySync ?? syncMemory)(memoryStore!, {
|
|
2060
2150
|
signal: syncStop.signal,
|
|
2151
|
+
placementAttestation: attestation.attest,
|
|
2061
2152
|
screen: row => policy.screen(row, true).ok,
|
|
2062
2153
|
policyVersion: () => memorySyncPolicyVersion(),
|
|
2063
2154
|
});
|
|
2155
|
+
const refused = attestation.refusal();
|
|
2156
|
+
if (refused) { recordFailure(writerFailure(refused)); return; }
|
|
2064
2157
|
if (memorySyncPolicyVersion() !== policyVersion) {
|
|
2065
2158
|
recordFailure({ cause: "binding" }, "binding changed");
|
|
2066
2159
|
return;
|
|
@@ -2108,7 +2201,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2108
2201
|
own = memoryStore ? nativeRecall([...goals, ...recallMemoryLines(screenMemory(memoryStore.list({ project: memoryProject, tier: "long", limit: Number.MAX_SAFE_INTEGER }).filter((v) => v.kind !== "finding")), 30, staleness())], config.memory.enabled ? Math.floor(config.memory.inject_tokens / 2) : config.memory.inject_tokens, (line) => sanitize(line.replace(/\s+/g, " "))) : undefined;
|
|
2109
2202
|
}
|
|
2110
2203
|
catch (e) { log(`hub memory recall failed: ${(e as Error).message}`); }
|
|
2111
|
-
const
|
|
2204
|
+
const recordRecallTask: BriefTask = { owner: peer, title: "", refs: {} };
|
|
2205
|
+
const recordRecallAllowed = () => !reviewOnlyPeer(peer) && !contextPii(peer) && !reviewObserverInput(peer) && recordReadAllowed(undefined, recordReadCwd(recordRecallTask));
|
|
2206
|
+
const recordRecallTokens = own ? Math.ceil(config.memory.inject_tokens / 2) : config.memory.inject_tokens;
|
|
2207
|
+
const theirs = config.memory.enabled ? await recallFor(peer, memory, chain, recordRecallTokens, {
|
|
2208
|
+
allowed: recordRecallAllowed, policyVersion: () => recordReadVersion(recordRecallTask),
|
|
2209
|
+
project: original => projectRecordRecall(peer, original, recordRecallTokens),
|
|
2210
|
+
observe: counts => { if (recordRecallAllowed()) log(`records recall before=${counts.beforeBytes} after=${counts.afterBytes}`); },
|
|
2211
|
+
}) : undefined;
|
|
2112
2212
|
const block = [own, theirs].filter(Boolean).join("\n\n");
|
|
2113
2213
|
if (!block || recalled.has(peer)) return; // nothing yet (worker down, no memory): the peer's next session tries again
|
|
2114
2214
|
recalled.add(peer);
|
|
@@ -2162,7 +2262,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2162
2262
|
try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
|
|
2163
2263
|
};
|
|
2164
2264
|
const inboxPolling = new InboxPolling();
|
|
2265
|
+
startupTimings.mark("model_runtime");
|
|
2165
2266
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
2267
|
+
const visuals = new VisualStore(join(opts.stateDir, "hub.db"));
|
|
2268
|
+
startupCleanup.push(() => visuals.close());
|
|
2269
|
+
// Held recovery must compare the source's exact rows before any retention mutation.
|
|
2270
|
+
if (!recoveryActive() && !bus.isRecoveryHeld) visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task));
|
|
2271
|
+
startupTimings.mark("board");
|
|
2166
2272
|
const reviewInputHeld = (peer: PeerId, envs: readonly Envelope[]): boolean => {
|
|
2167
2273
|
if (envs.some(isReviewObserverExcluded)) return true;
|
|
2168
2274
|
const taskIds = envs.flatMap(env => {
|
|
@@ -2397,6 +2503,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2397
2503
|
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
2398
2504
|
const memorySymbols = new Map<string, MemorySymbolNodes>();
|
|
2399
2505
|
startupCleanup.push(() => board.close());
|
|
2506
|
+
const modelOverrides = new ModelOverrides(join(opts.stateDir, "hub.db"));
|
|
2507
|
+
const modelLatches = new ModelOverrideLatches(modelOverrides);
|
|
2508
|
+
const modelManagedOwners = new WeakSet<PeerAdapter>();
|
|
2509
|
+
const modelAcpOwners = new WeakMap<PeerAdapter, AcpPeer>();
|
|
2510
|
+
const modelLaunchModels = new WeakMap<PeerAdapter, string>();
|
|
2511
|
+
startupCleanup.push(() => modelOverrides.close());
|
|
2400
2512
|
startupCleanup.push(() => codeFacts.close());
|
|
2401
2513
|
const supervision = new SupervisionFeed({
|
|
2402
2514
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
@@ -2838,6 +2950,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2838
2950
|
{
|
|
2839
2951
|
allowed: corpusBriefAllowed, policyVersion: corpusPolicyVersion,
|
|
2840
2952
|
read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
|
|
2953
|
+
}, {
|
|
2954
|
+
allowed: recordReadAllowed, policyVersion: recordReadVersion, read: readRecordSnapshot,
|
|
2955
|
+
observe: (_peer, task, counts) => { const row = task === undefined ? undefined : board.get(task); if (row && recordReadAllowed(row)) log(`records brief before=${counts.beforeBytes} after=${counts.afterBytes} replaced=${counts.replacedNotes} records=${counts.records}`); },
|
|
2841
2956
|
}),
|
|
2842
2957
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
2843
2958
|
const read = () => {
|
|
@@ -2851,7 +2966,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2851
2966
|
};
|
|
2852
2967
|
return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
|
|
2853
2968
|
} } : {}),
|
|
2854
|
-
...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
|
|
2969
|
+
...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onGeneratedNote: (proof: GeneratedNoteProof) => generatedRecordNotes.record(proof), onNote: (id: string) => void suggestEdges(id) } } : {}),
|
|
2855
2970
|
notify,
|
|
2856
2971
|
recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
|
|
2857
2972
|
checkEnded: owner => settleCheck(owner), // #1054: a run waited on its owner's completion check
|
|
@@ -3134,14 +3249,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3134
3249
|
// same peer cannot undo a settlement.
|
|
3135
3250
|
const turnEnded = new Map<PeerId, number>();
|
|
3136
3251
|
const nativeCompletedAt = new Map<PeerId, number>();
|
|
3137
|
-
|
|
3252
|
+
startupTimings.mark("task_runtime");
|
|
3253
|
+
const startupSeeds = readStartupEventSeeds(join(opts.stateDir, "events.jsonl"));
|
|
3254
|
+
try { opts.startupSeedObserved?.({ readPasses: startupSeeds.readPasses, readBytes: startupSeeds.readBytes }); } catch { /* Diagnostic observers cannot change replay or readiness. */ }
|
|
3255
|
+
const nativeStopsSeen = startupSeeds.nativeStopsSeen;
|
|
3256
|
+
startupTimings.mark("native_stop_seed");
|
|
3138
3257
|
const activeAt = new Map<PeerId, number>();
|
|
3139
3258
|
interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
|
|
3140
3259
|
const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
|
|
3141
3260
|
const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
|
|
3142
3261
|
const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
|
|
3143
|
-
/** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
|
|
3262
|
+
/** Every Claude or managed native hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
|
|
3144
3263
|
const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
|
|
3264
|
+
const tallyHook = (peer: PeerId, callStarted: number, startedMs: unknown) => {
|
|
3265
|
+
const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
|
|
3266
|
+
const startup = typeof startedMs === "number" && Number.isFinite(startedMs) ? Math.max(0, startedMs) : 0;
|
|
3267
|
+
hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
|
|
3268
|
+
};
|
|
3269
|
+
const emitHookStats = (peer: PeerId) => {
|
|
3270
|
+
const st = hookStats.get(peer);
|
|
3271
|
+
if (!st) return;
|
|
3272
|
+
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
3273
|
+
hookStats.delete(peer);
|
|
3274
|
+
};
|
|
3145
3275
|
/**
|
|
3146
3276
|
* Between native turns now: Claude stopped after its last tool call began (its channel going offline says nothing
|
|
3147
3277
|
* about its session, whose hooks may still run); any other peer's adapter is not in a turn. The adapter's own state,
|
|
@@ -3168,7 +3298,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3168
3298
|
}
|
|
3169
3299
|
if ((h.event === "proposed" || h.event === "backlog pulled" || h.event === "catalog role changed") && (t.role ? catalog.roles.has(t.role) : h.event === "catalog role changed")) event({ type: "role", task: t.id, actor: h.by, stage: "assigned", roles: t.role ? [roleIdentity(catalog.roles.get(t.role)!)] : [] });
|
|
3170
3300
|
event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, ...(t.profile ? { profile: t.profile, profileClass: t.profileClass } : {}), ...(t.role ? { role: t.role, ...(catalog.roles.has(t.role) ? { roleIdentity: roleIdentity(catalog.roles.get(t.role)!) } : {}) } : {}), pii: tasks.isPii(t), ...(t.planningRevision ? { release: t.release ?? null, priority: t.priority ?? null, plannedStart: t.plannedStart ?? null, due: t.due ?? null } : {}), ...(h.reason ? { reason: h.reason } : {}) });
|
|
3171
|
-
|
|
3301
|
+
// #1277 AC6: a cancelled task is a terminal task too, and the guarded merge writes its receipt after the approval, so its result completes the record's merge block.
|
|
3302
|
+
if (config.research.enabled && ((t.state === "approved" && (h.event === "approved" || h.event === "done" || h.event === "merge result")) || (t.state === "cancelled" && h.event === "cancelled"))) recordResearch(t.id);
|
|
3172
3303
|
if (memoryStore && (t.state === "in_progress" || h.event === "done") && !tasks.isPii(t)) renewLeases(t);
|
|
3173
3304
|
if ((h.event === "proposed" || h.event === "backlog pulled") && projections.enabled && turnLog) {
|
|
3174
3305
|
const created = taskProposalAt(t);
|
|
@@ -3193,6 +3324,161 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3193
3324
|
};
|
|
3194
3325
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
3195
3326
|
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
3327
|
+
const recordOwners = new WeakMap<PeerAdapter, { cwd: string; session(): string | undefined }>();
|
|
3328
|
+
const recordInputs = new Map<PeerId, { owner: PeerAdapter | undefined; originals: readonly Envelope[]; tainted?: true }>();
|
|
3329
|
+
const recordFrames = new Map<PeerId, { owner: PeerAdapter; turn: NonNullable<ReturnType<typeof turns.get>>; capture: RecordCapture }>();
|
|
3330
|
+
function recordPolicy(cwd: string): { enabled: boolean; version: string } | undefined {
|
|
3331
|
+
try {
|
|
3332
|
+
if (opts.effectiveRecordPolicy) {
|
|
3333
|
+
const policy = opts.effectiveRecordPolicy(cwd);
|
|
3334
|
+
return policy && typeof policy.enabled === "boolean" && typeof policy.version === "string" && policy.version ? { enabled: policy.enabled, version: policy.version } : undefined;
|
|
3335
|
+
}
|
|
3336
|
+
const binding = captureBinding;
|
|
3337
|
+
if (!binding || memoryProblem) return;
|
|
3338
|
+
binding.assertCurrent();
|
|
3339
|
+
const enabled = recordsEnabled(binding.home, cwd);
|
|
3340
|
+
// This revision binds only the public flag; fresh configuredPolicy screens below bind current privacy policy.
|
|
3341
|
+
return { enabled, version: enabled ? "records:true" : "records:false" };
|
|
3342
|
+
}
|
|
3343
|
+
catch { return; }
|
|
3344
|
+
}
|
|
3345
|
+
function recordPublic(text: string): boolean { return !looksSecret(text) && boundedSafe(text) && tasks.publicMetadataText(text); }
|
|
3346
|
+
function recordPathDenied(path: string, cwd = opts.cwd): boolean { return isDenied(path, config.local.deny) || matchesPolicy(cwd, path, config.pii?.paths ?? [], true) || matchesPolicy(opts.cwd, resolve(cwd, path), config.pii?.paths ?? [], true); }
|
|
3347
|
+
function recordInputAllowed(peer: PeerId, envs: readonly Envelope[]): boolean {
|
|
3348
|
+
try { return !!envs.length && !reviewInputHeld(peer, envs) && !reviewObserverInput(peer) && !envs.some(env => env.private || isReviewObserverExcluded(env) || isInvocationKind(env) || remoteEnvelope(env) || env.to?.some(id => id.includes("@"))); }
|
|
3349
|
+
catch { return false; }
|
|
3350
|
+
}
|
|
3351
|
+
function recordOwnerEligible(owner: PeerAdapter): boolean {
|
|
3352
|
+
const recordPeerId = owner.id, input = recordInputs.get(recordPeerId), proxy = frontendProxyState(recordPeerId, owner);
|
|
3353
|
+
if (proxy.present && (!proxy.port || proxy.port.attached)) return false;
|
|
3354
|
+
return config.memory.enabled && !stopping && !recoveryActive() && !bus.isRecoveryHeld && recordOwners.has(owner) && bus.peers.get(recordPeerId) === owner
|
|
3355
|
+
&& membershipAllowed(recordPeerId) && owner.state === "busy" && !reviewOnlyPeer(recordPeerId) && !definitionOf(recordPeerId)?.reviewer && !contextPii(recordPeerId)
|
|
3356
|
+
&& !!input && !input.tainted && input.owner === owner && recordInputAllowed(recordPeerId, input.originals);
|
|
3357
|
+
}
|
|
3358
|
+
function noteRecordSteered(peer: PeerId, originals: readonly Envelope[]): void {
|
|
3359
|
+
const input = recordInputs.get(peer), owner = bus.peers.get(peer);
|
|
3360
|
+
if (!input) { recordInputs.set(peer, { owner, originals: [], tainted: true }); return; }
|
|
3361
|
+
if (!input.tainted && (!owner || input.owner !== owner || !recordInputAllowed(peer, originals))) input.tainted = true;
|
|
3362
|
+
}
|
|
3363
|
+
function closeRecordFrame(peer: PeerId): void { recordFrames.get(peer)?.capture.close(); recordFrames.delete(peer); }
|
|
3364
|
+
function bindRecordOwner(owner: PeerAdapter, cwd: string, session: () => string | undefined): void {
|
|
3365
|
+
recordOwners.set(owner, { cwd, session });
|
|
3366
|
+
owner.recordInstruction = () => {
|
|
3367
|
+
if (!recordOwnerEligible(owner)) return;
|
|
3368
|
+
const turn = turns.get(owner.id), bound = recordOwners.get(owner), prior = recordFrames.get(owner.id);
|
|
3369
|
+
if (!turn || !bound) return;
|
|
3370
|
+
if (prior?.owner === owner && prior.turn === turn) return prior.capture.instruction();
|
|
3371
|
+
closeRecordFrame(owner.id);
|
|
3372
|
+
const session = bound.session();
|
|
3373
|
+
if (!session) return;
|
|
3374
|
+
const effective = recordPolicy(cwd);
|
|
3375
|
+
if (!effective?.enabled || !captureBinding || memoryProblem) return;
|
|
3376
|
+
const binding = captureBinding, profile = memorySyncPolicyVersion(), revision = configRevision, generation = owner.sessionGeneration;
|
|
3377
|
+
const current = () => {
|
|
3378
|
+
if (!recordOwnerEligible(owner) || turns.get(owner.id) !== turn || bound.session() !== session || owner.sessionGeneration !== generation || configRevision !== revision || captureBinding !== binding || memoryProblem) return false;
|
|
3379
|
+
const policy = recordPolicy(cwd);
|
|
3380
|
+
if (policy?.enabled !== true || policy.version !== effective.version || memorySyncPolicyVersion() !== profile) return false;
|
|
3381
|
+
try { binding.assertCurrent(); return memoryProjectFor(opts.cwd) === memoryProject; } catch { return false; }
|
|
3382
|
+
};
|
|
3383
|
+
const policy: RecordCapturePolicy = { records: true, current, project: memoryProject, cwd, peer: owner.id, turn: turn.id, session, at: new Date(turn.start).toISOString(), memoryDir: binding.home,
|
|
3384
|
+
publicText: text => { try { return current() && configuredPolicy(binding.home, cwd).screenStandalone(text) && recordPublic(text) && current(); } catch { return false; } },
|
|
3385
|
+
denyPath: path => recordPathDenied(path, cwd) };
|
|
3386
|
+
const capture = new RecordCapture({ eligible: () => recordOwnerEligible(owner) && turns.get(owner.id) === turn, policy: () => current() ? policy : undefined,
|
|
3387
|
+
observe: counts => { if (current()) log(`records capture accepted=${counts.accepted} dropped=${counts.dropped} bytes=${counts.bytes}`); } });
|
|
3388
|
+
recordFrames.set(owner.id, { owner, turn, capture });
|
|
3389
|
+
return capture.instruction();
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
function recordReadCwd(task?: Pick<BriefTask, "owner">): string {
|
|
3393
|
+
const owner = task?.owner ? bus.peers.get(task.owner) : undefined;
|
|
3394
|
+
return owner ? recordOwners.get(owner)?.cwd ?? opts.cwd : opts.cwd;
|
|
3395
|
+
}
|
|
3396
|
+
function recordReadAllowed(task?: BriefTask, cwd = recordReadCwd(task)): boolean {
|
|
3397
|
+
try { return config.memory.enabled && !stopping && !recoveryActive() && !bus.isRecoveryHeld && !memoryProblem && !!captureBinding
|
|
3398
|
+
&& (task ? corpusBriefAllowed(task) : true) && recordPolicy(cwd)?.enabled === true; } catch { return false; }
|
|
3399
|
+
}
|
|
3400
|
+
function recordReadVersion(task?: BriefTask): string { const cwd = recordReadCwd(task); return JSON.stringify([memorySyncPolicyVersion(), cwd, recordPolicy(cwd)]); }
|
|
3401
|
+
function readRecordSnapshot(task?: BriefTask, peer?: PeerId): import("../memory/brief.ts").RecordBriefSnapshot | undefined {
|
|
3402
|
+
const selectedPeer = task?.owner ?? peer;
|
|
3403
|
+
if (selectedPeer && (reviewOnlyPeer(selectedPeer) || contextPii(selectedPeer) || reviewObserverInput(selectedPeer))) return;
|
|
3404
|
+
const context = task ?? (peer ? { owner: peer, title: "", refs: {} } : undefined), cwd = recordReadCwd(context), owner = selectedPeer ? bus.peers.get(selectedPeer) : undefined;
|
|
3405
|
+
if (!recordReadAllowed(task, cwd)) return;
|
|
3406
|
+
const version = recordReadVersion(context), binding = captureBinding!;
|
|
3407
|
+
let store: CaptureStore | undefined;
|
|
3408
|
+
try {
|
|
3409
|
+
binding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) return;
|
|
3410
|
+
const policy = configuredPolicy(binding.home, cwd);
|
|
3411
|
+
store = new CaptureStore({ memoryDir: binding.home, project: memoryProject, cwd, readOnly: true });
|
|
3412
|
+
const snapshot = store.readBriefRecords(32);
|
|
3413
|
+
if (!recordReadAllowed(task, cwd) || recordReadVersion(context) !== version) return;
|
|
3414
|
+
const paths = task ? [...task.refs.paths ?? [], ...task.plan?.paths ?? []] : undefined;
|
|
3415
|
+
const publicRow = (row: (typeof snapshot.rows)[number], currentPolicy: ReturnType<typeof configuredPolicy>) => row.event === "Record" && !row.subagent && Array.isArray(row.paths)
|
|
3416
|
+
&& currentPolicy.screenStandalone(row) && currentPolicy.screenStandalone(row.excerpt) && currentPolicy.screenStandalone(row.paths) && recordPublic(JSON.stringify(row))
|
|
3417
|
+
&& sanitize(row.excerpt) === row.excerpt && row.paths.every(anchor => typeof anchor === "string" && !recordPathDenied(anchor.split("#")[0]!, cwd));
|
|
3418
|
+
const rows = snapshot.rows.filter(row => publicRow(row, policy)
|
|
3419
|
+
&& (!paths || row.paths.length === 0 || row.paths.some(anchor => paths.some(path => anchor === path || anchor.split("#")[0] === path))));
|
|
3420
|
+
const current = (text?: string) => {
|
|
3421
|
+
try {
|
|
3422
|
+
if (selectedPeer && (bus.peers.get(selectedPeer) !== owner || reviewOnlyPeer(selectedPeer) || contextPii(selectedPeer) || reviewObserverInput(selectedPeer))) return false;
|
|
3423
|
+
if (captureBinding !== binding || recordReadCwd(context) !== cwd || !recordReadAllowed(task, cwd) || recordReadVersion(context) !== version) return false;
|
|
3424
|
+
binding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) return false;
|
|
3425
|
+
const fresh = configuredPolicy(binding.home, cwd);
|
|
3426
|
+
return rows.every(row => publicRow(row, fresh)) && (text === undefined || typeof text === "string" && fresh.screenStandalone(text) && recordPublic(text));
|
|
3427
|
+
} catch { return false; }
|
|
3428
|
+
};
|
|
3429
|
+
if (!current()) return;
|
|
3430
|
+
return { rows, replaceable: generatedRecordNotes.matching([...new Set(rows.flatMap(row => row.paths))], 32), partial: snapshot.partial, current };
|
|
3431
|
+
} catch { return; } finally { try { store?.close(); } catch { /* Read preference never changes the old brief. */ } }
|
|
3432
|
+
}
|
|
3433
|
+
function projectRecordRecall(peer: PeerId, original: string, tokens: number): import("../memory/recall.ts").RecordRecallProjection | undefined {
|
|
3434
|
+
const header = "# [amem] recent curated context\n\n### Curated short-term notes\n";
|
|
3435
|
+
if (typeof original !== "string" || original.length > 64 * 1024 || Buffer.byteLength(original) > 64 * 1024 || !original.startsWith(header) || original.includes("\r") || !Number.isSafeInteger(tokens) || tokens < 1) return;
|
|
3436
|
+
const owner = bus.peers.get(peer), context: BriefTask = { owner: peer, title: "", refs: {} };
|
|
3437
|
+
const allowed = () => bus.peers.get(peer) === owner && !reviewOnlyPeer(peer) && !contextPii(peer) && !reviewObserverInput(peer) && recordReadAllowed(undefined, recordReadCwd(context));
|
|
3438
|
+
if (!allowed()) return;
|
|
3439
|
+
const version = recordReadVersion(context), current = () => allowed() && recordReadVersion(context) === version;
|
|
3440
|
+
if (!current()) return;
|
|
3441
|
+
const body = original.slice(header.length).split("\n"), items: NativeItem[] = [], ids = new Set<string>(), protectedIds = new Set<string>();
|
|
3442
|
+
const row = /^(mem:[a-f0-9]{32}) (decision|finding|contract|fail): /;
|
|
3443
|
+
for (const line of body) {
|
|
3444
|
+
const match = row.exec(line);
|
|
3445
|
+
if (!match) { if (line.startsWith("mem:")) return; continue; }
|
|
3446
|
+
const id = match[1]!;
|
|
3447
|
+
if (ids.has(id) || items.length >= 32) return;
|
|
3448
|
+
ids.add(id); items.push({ id, kind: match[2]!, line });
|
|
3449
|
+
if (!generatedRecordNotes.get(id)) protectedIds.add(id);
|
|
3450
|
+
}
|
|
3451
|
+
const snapshot = readRecordSnapshot(undefined, peer);
|
|
3452
|
+
let projectedText = original;
|
|
3453
|
+
const projectionCurrent = () => current() && (snapshot?.current === undefined || snapshot.current(original) === true && snapshot.current(projectedText) === true);
|
|
3454
|
+
if (!snapshot || !projectionCurrent()) return;
|
|
3455
|
+
const staticBytes = Buffer.byteLength(original) - Buffer.byteLength(items.map(item => item.line).join("\n"));
|
|
3456
|
+
const preferred = preferRecordItems(items, snapshot, 32, protectedIds, Math.max(0, tokens * 3 - staticBytes));
|
|
3457
|
+
if (!projectionCurrent()) return;
|
|
3458
|
+
const output: string[] = []; let index = 0;
|
|
3459
|
+
for (const line of body) {
|
|
3460
|
+
const match = row.exec(line);
|
|
3461
|
+
if (!match) { output.push(line); continue; }
|
|
3462
|
+
if (preferred.items.some(item => item.id === match[1])) {
|
|
3463
|
+
while (index < preferred.items.length && !ids.has(preferred.items[index]!.id)) output.push(preferred.items[index++]!.line);
|
|
3464
|
+
if (preferred.items[index]?.id !== match[1]) return;
|
|
3465
|
+
output.push(line); index++;
|
|
3466
|
+
} else {
|
|
3467
|
+
while (index < preferred.items.length && !ids.has(preferred.items[index]!.id)) output.push(preferred.items[index++]!.line);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
while (index < preferred.items.length) { const item = preferred.items[index++]!; if (ids.has(item.id)) return; output.push(item.line); }
|
|
3471
|
+
const after = header + output.join("\n"); projectedText = after;
|
|
3472
|
+
if (!projectionCurrent() || Buffer.byteLength(after) > Math.max(Buffer.byteLength(original), tokens * 3)) return;
|
|
3473
|
+
return { before: original, after, records: preferred.records, current: projectionCurrent };
|
|
3474
|
+
}
|
|
3475
|
+
bus.onFinalAnswer = (peer, raw, input) => {
|
|
3476
|
+
const frame = recordFrames.get(peer);
|
|
3477
|
+
if (!frame || !recordOwnerEligible(frame.owner) || turns.get(peer) !== frame.turn || !recordInputAllowed(peer, input.out) || !recordInputAllowed(peer, input.originals)) return;
|
|
3478
|
+
frame.capture.finish(raw);
|
|
3479
|
+
};
|
|
3480
|
+
bus.onFinalAnswerBytes = (peer, counts) => { const owner = bus.peers.get(peer); if (owner && recordOwnerEligible(owner)) log(`records forwarded before=${counts.before} after=${counts.after}`); };
|
|
3481
|
+
|
|
3196
3482
|
// Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
|
|
3197
3483
|
function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
|
|
3198
3484
|
return () => {
|
|
@@ -3400,28 +3686,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3400
3686
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
3401
3687
|
}, accountMembers, accountOf));
|
|
3402
3688
|
budget.setRecoveryHold(recoveryActive());
|
|
3403
|
-
//
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
|
|
3689
|
+
// Share the single streaming startup pass; quota, Kimi and token-window replay keep their existing predicates.
|
|
3690
|
+
startupTimings.mark("budget");
|
|
3691
|
+
const startupEvents = startupSeeds.events;
|
|
3407
3692
|
budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
|
|
3408
3693
|
// #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
|
|
3694
|
+
const kimiEvents = new KimiEventWriter({ append: event, read: () => readEvents(join(opts.stateDir, "events.jsonl")) }, startupEvents);
|
|
3409
3695
|
const kimiUsage = new KimiUsage({
|
|
3410
3696
|
home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
|
|
3411
3697
|
now: () => Date.now(),
|
|
3412
3698
|
ceiling: () => config.budget.kimi_tokens_5h,
|
|
3413
3699
|
session: peer => contextSession(peer),
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3700
|
+
schedule: opts.kimiUsageScheduler ?? ((delay, run) => {
|
|
3701
|
+
const timer = setTimeout(run, delay); timer.unref?.();
|
|
3702
|
+
return () => clearTimeout(timer);
|
|
3703
|
+
}),
|
|
3704
|
+
record: (peer, row, counted, attributed) => {
|
|
3705
|
+
kimiEvents.write(peer, row, counted, attributed);
|
|
3706
|
+
applyKimiTokens(peer, counted, attributed);
|
|
3419
3707
|
},
|
|
3420
3708
|
report: (peer, window) => budget.report(peer, [{ id: "tokens", kind: "count", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
|
|
3421
3709
|
forget: peer => budget.forget(peer, "tokens", "count"),
|
|
3422
3710
|
notice: line => notify(line),
|
|
3423
3711
|
});
|
|
3712
|
+
startupCleanup.push(() => kimiUsage.stop());
|
|
3424
3713
|
kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
|
|
3714
|
+
startupTimings.mark("usage_seed");
|
|
3425
3715
|
/** Why a peer shows no number (no quota, nothing read yet): one line each, from the definition and the counters, never from a log's text. */
|
|
3426
3716
|
const budgetPeerNotes = (): BudgetPeerNote[] => {
|
|
3427
3717
|
const quota = budget.status();
|
|
@@ -3429,15 +3719,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3429
3719
|
return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
|
|
3430
3720
|
hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
|
|
3431
3721
|
};
|
|
3432
|
-
/** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a
|
|
3722
|
+
/** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end, a usage_update and a bounded delayed drain. */
|
|
3433
3723
|
const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
|
|
3434
|
-
const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
3724
|
+
const collectKimiUsage = (peer: PeerId, terminal = false): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer, false, terminal); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
3435
3725
|
startupCleanup.push(() => budget.close());
|
|
3436
3726
|
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
3437
3727
|
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
3438
3728
|
startupCleanup.push(() => usageRollup.close());
|
|
3439
3729
|
const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
|
|
3440
3730
|
pollUsageRollup();
|
|
3731
|
+
startupTimings.mark("usage_rollup");
|
|
3441
3732
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
3442
3733
|
const runId = Date.now().toString(36);
|
|
3443
3734
|
let turnSeq = 0;
|
|
@@ -3447,11 +3738,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3447
3738
|
const task = result.task === undefined ? undefined : board.get(result.task);
|
|
3448
3739
|
return { ...result, ...(task && tasks.isPii(task) ? { pii: true as const } : {}) };
|
|
3449
3740
|
};
|
|
3450
|
-
const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
|
|
3741
|
+
const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string; nativeTurn?: string }>();
|
|
3451
3742
|
/** #1054: who started the turn a delivery begins. The person's message (console, `ahub say`, an acon send: from `user`, never the hub's own `answer` envelope) opens a run; ids only. */
|
|
3452
3743
|
const pendingPersonDelivery = new Map<PeerId, { envelope: string; private: boolean }>();
|
|
3744
|
+
/** A native turn whose transcript, not its hook payload, proved a person origin. Native prompt text is never available here, so the result stays private. */
|
|
3745
|
+
const pendingNativePerson = new Set<PeerId>();
|
|
3453
3746
|
bus.onDeliver = (peer, originals) => {
|
|
3454
3747
|
if (peer.includes("@")) return;
|
|
3748
|
+
recordInputs.set(peer, { owner: bus.peers.get(peer), originals: [...originals] });
|
|
3455
3749
|
resetReviewObserverSessionIfChanged(peer);
|
|
3456
3750
|
if (!presenceOnlyDelivery(originals)) {
|
|
3457
3751
|
const pendingBefore = pendingReviewObserverInputs.get(peer), previous = pendingBefore ?? currentReviewObserverInputs.get(peer), reviewInputScoped = reviewInputHeld(peer, originals), turnId = turns.get(peer)?.id;
|
|
@@ -3579,7 +3873,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3579
3873
|
supervisionTurns.set(peer, { id: turn?.id ?? `supervision-${randomUUID()}`, at: turn?.start ?? Date.now(), ...(turn?.tokens ? { tokens: turn.tokens } : {}), ...(contextSession(peer) ? { session: contextSession(peer) } : {}) });
|
|
3580
3874
|
};
|
|
3581
3875
|
bus.onPulled = (peer, originals) => { notePulledReviewInput(peer, originals); };
|
|
3582
|
-
bus.onSteering = (peer, originals) => { noteSteeredReviewInput(peer, originals); };
|
|
3876
|
+
bus.onSteering = (peer, originals) => { try { noteSteeredReviewInput(peer, originals); } finally { noteRecordSteered(peer, originals); } };
|
|
3583
3877
|
bus.onDeliveryFailed = peer => {
|
|
3584
3878
|
pendingDeliveryTask.delete(peer); pendingPersonDelivery.delete(peer); deliveredAwaitingTurn.delete(peer); supervisionTurns.delete(peer);
|
|
3585
3879
|
const pending = pendingReviewObserverInputs.get(peer);
|
|
@@ -4014,9 +4308,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4014
4308
|
const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
|
|
4015
4309
|
const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
|
|
4016
4310
|
// Replayed through `seed`, which skips rows older than 5 h before inserting any: a long-lived project's whole log costs one pass, not one search per row.
|
|
4311
|
+
startupTimings.mark("token_runtime");
|
|
4017
4312
|
const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
|
|
4018
4313
|
for (const e of startupEvents) if (e.type === "tokens" && noQuotaPeer(e.peer)) (seedRows.get(e.peer) ?? seedRows.set(e.peer, []).get(e.peer)!).push({ at: Date.parse(e.at), n: e.n });
|
|
4019
4314
|
for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
|
|
4315
|
+
const applyKimiTokens = (peer: PeerId, n: number, attributed: KimiRowAttribution): void => {
|
|
4316
|
+
if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
|
|
4317
|
+
const supervisionTurn = supervisionTurns.get(peer);
|
|
4318
|
+
if (supervisionTurn && Number.isFinite(n) && n >= 0 && (supervisionTurn.nativeTurn ?? supervisionTurn.id) === attributed.turn) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
4319
|
+
if (n > 0) {
|
|
4320
|
+
const turn = turns.get(peer);
|
|
4321
|
+
if (turn && turn.id === attributed.turn) turn.tokens += n;
|
|
4322
|
+
}
|
|
4323
|
+
};
|
|
4324
|
+
startupTimings.mark("token_seed");
|
|
4020
4325
|
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
4021
4326
|
if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
|
|
4022
4327
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
@@ -4035,11 +4340,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4035
4340
|
const claudeContextSeen = new Map<string, string>();
|
|
4036
4341
|
const contextTailSeen = new Map<PeerId, string>();
|
|
4037
4342
|
const retainedCliAudits = new Set<string>();
|
|
4343
|
+
/**
|
|
4344
|
+
* The period of the sweep that drains the CLI audit outbox (the interval below runs `drainCliAuditsNow` on it). A row is attributed to the peer's task only when it is at most this many
|
|
4345
|
+
* periods old at the drain: an ordinary row is drained within one period, so fifteen leaves room for a slow sweep, while a row left by a stalled hub or an unreadable outbox is not matched to
|
|
4346
|
+
* whatever task is open when it is finally read. The bound is derived from the period, so the two cannot drift apart.
|
|
4347
|
+
*/
|
|
4348
|
+
const CLI_AUDIT_SWEEP_MS = 1000;
|
|
4349
|
+
const CLI_AUDIT_FRESH_MS = 15 * CLI_AUDIT_SWEEP_MS;
|
|
4038
4350
|
function drainCliAuditsNow(): void {
|
|
4039
4351
|
for (const audit of drainCliAudits(opts.stateDir)) {
|
|
4040
4352
|
const delegated = audit.outcome === "delegated";
|
|
4041
4353
|
const refused = audit.outcome !== "run" && !delegated;
|
|
4042
|
-
|
|
4354
|
+
// #1278: the audit row is written when the command runs and read here on a later sweep, so the peer's open tasks now may not be the ones then. A row older than the drain's own
|
|
4355
|
+
// freshness bound is `unattributed` (what is known), never matched to whatever is open at drain time; a fresh one uses the rule `turn_end` uses.
|
|
4356
|
+
const fresh = Date.now() - audit.at <= CLI_AUDIT_FRESH_MS && Date.now() >= audit.at;
|
|
4357
|
+
if (!event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}), ...(fresh ? taskAttribution(audit.peer) : { attribution: "unattributed" as const }) })) {
|
|
4043
4358
|
throw new Error("CLI audit event was not retained");
|
|
4044
4359
|
}
|
|
4045
4360
|
retainedCliAudits.add(audit.id); // Only after the event append succeeded.
|
|
@@ -4135,7 +4450,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4135
4450
|
const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
|
|
4136
4451
|
if (reading) contexts.report(claudePeer, reading, bound);
|
|
4137
4452
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
4138
|
-
},
|
|
4453
|
+
}, CLI_AUDIT_SWEEP_MS),
|
|
4139
4454
|
setInterval(() => budget.tick(), 30_000),
|
|
4140
4455
|
setInterval(pollUsageRollup, 10_000),
|
|
4141
4456
|
// #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
|
|
@@ -4159,6 +4474,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4159
4474
|
void tasks.runSecurityScan().catch(error => log(`sdlc security scan failed: ${sanitize((error as Error).message).slice(0, 160)}`));
|
|
4160
4475
|
}, opts.securityScanEveryMs ?? 3_600_000),
|
|
4161
4476
|
setInterval(() => {
|
|
4477
|
+
if (!stopping && !recoveryActive() && !bus.isRecoveryHeld) {
|
|
4478
|
+
try { visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task)); }
|
|
4479
|
+
catch { /* retention retries on the existing maintenance tick */ }
|
|
4480
|
+
}
|
|
4162
4481
|
if (!ready || !memoryBinding) return;
|
|
4163
4482
|
const before = memoryProblem;
|
|
4164
4483
|
memoryBinding.status(memoryStore);
|
|
@@ -4546,7 +4865,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4546
4865
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
4547
4866
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
4548
4867
|
const [by, op, input] = args;
|
|
4549
|
-
if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
|
|
4868
|
+
if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw op === "hub_visual" ? visualOperational("role") : new PreEffectToolRefusal("read-only instance cannot mutate hub state");
|
|
4550
4869
|
const sourceCurrent = op === "hub_source_read" ? sourceReadCurrent(by, args[3] ?? false, args[4] ?? false) : undefined;
|
|
4551
4870
|
const nativeTurn = turns.get(by);
|
|
4552
4871
|
const effectful = ownerToolEffect(op, input);
|
|
@@ -4569,13 +4888,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4569
4888
|
const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
|
|
4570
4889
|
auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
|
|
4571
4890
|
}
|
|
4572
|
-
const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" ? ownHeldHint(by) : "";
|
|
4891
|
+
const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" && op !== "hub_visual" && op !== "hub_visual_show" ? ownHeldHint(by) : "";
|
|
4573
4892
|
return hint ? `${text}\n\n${hint}` : text;
|
|
4574
4893
|
} catch (error) {
|
|
4575
4894
|
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
|
|
4576
4895
|
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
4577
4896
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
4578
|
-
const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" ? ownHeldHint(by) : "";
|
|
4897
|
+
const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" && op !== "hub_visual" && op !== "hub_visual_show" ? ownHeldHint(by) : "";
|
|
4579
4898
|
if (hint && error instanceof Error) error.message += `\n\n${hint}`;
|
|
4580
4899
|
throw error;
|
|
4581
4900
|
} finally {
|
|
@@ -4741,7 +5060,197 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4741
5060
|
catch (error) { finishInvocation(row, "refused", { reason: "the hub could not queue the request; nothing ran" }); throw new PreEffectToolRefusal(error instanceof Error ? error.message : "the request could not be queued"); }
|
|
4742
5061
|
return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
|
|
4743
5062
|
}
|
|
5063
|
+
const visualUnavailable = () => new PreEffectToolRefusal("visual unavailable under the current privacy or project policy; use a public visual in an ordinary context and retry");
|
|
5064
|
+
const visualOperational = (cause: "stopping" | "recovery" | "authority" | "role" | "membership" | "observer" | "storage" | "concurrency" | "screen" | "policy") => new PreEffectToolRefusal({
|
|
5065
|
+
stopping: "visual unavailable: hub is stopping; wait for the hub to restart and retry",
|
|
5066
|
+
recovery: "visual operation is held by controlled recovery; check ahub status, wait for recovery release and retry",
|
|
5067
|
+
authority: "visual request authority changed; reconnect your current peer session and retry",
|
|
5068
|
+
role: "visual publication requires remember capability and a writable peer role; ask the person to update the peer role or capability and retry",
|
|
5069
|
+
membership: "visual peer is off roster; ask the person to enable it and retry",
|
|
5070
|
+
observer: "visual publication is held by an open review observer scope; finish or settle the review and start an ordinary turn",
|
|
5071
|
+
storage: "visual delivery storage is unavailable; inspect ahub status and retry after storage recovers",
|
|
5072
|
+
concurrency: "visual generation, task or policy changed while checking; inspect current task/session and retry",
|
|
5073
|
+
screen: "visual privacy screen is unavailable or returned no clear verdict; shorten the visual if it exceeds the screen limit, or restore the configured screen, check ahub status and retry",
|
|
5074
|
+
policy: "visual file policy is unavailable; repair the project configuration and retry",
|
|
5075
|
+
}[cause]);
|
|
5076
|
+
type VisualFilePolicy = { pii: string[]; deny: string[] };
|
|
5077
|
+
let visualPolicyCache: { key: string; value: VisualFilePolicy } | undefined;
|
|
5078
|
+
// These two non-machine-local path policies use ordinary per-key local-over-shared precedence.
|
|
5079
|
+
// Reading a candidate cannot relax accepted runtime restrictions until they are actually adopted.
|
|
5080
|
+
const visualFilePolicy = (): VisualFilePolicy => {
|
|
5081
|
+
try {
|
|
5082
|
+
const texts = configSourceTexts(opts.cwd);
|
|
5083
|
+
const key = JSON.stringify([texts["config.json"], texts["config.local.json"], config.pii?.paths ?? [], config.local.deny]);
|
|
5084
|
+
if (visualPolicyCache?.key === key) return visualPolicyCache.value;
|
|
5085
|
+
let pii: string[] = [], deny: string[] = [];
|
|
5086
|
+
for (const name of CONFIG_FILES) {
|
|
5087
|
+
if (texts[name] === null) continue;
|
|
5088
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
5089
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw visualOperational("policy");
|
|
5090
|
+
const input = raw as Record<string, unknown>;
|
|
5091
|
+
if (input.pii !== undefined) {
|
|
5092
|
+
const policies: Record<string, unknown> = { pii: input.pii };
|
|
5093
|
+
validateRawConfig(policies, declarations, adapterCatalog);
|
|
5094
|
+
const value = input.pii as Record<string, unknown>;
|
|
5095
|
+
if (Object.hasOwn(value, "paths")) pii = policyPaths(value.paths, "pii.paths");
|
|
5096
|
+
}
|
|
5097
|
+
if (input.local !== undefined) {
|
|
5098
|
+
if (!input.local || typeof input.local !== "object" || Array.isArray(input.local)) throw visualOperational("policy");
|
|
5099
|
+
const value = input.local as Record<string, unknown>;
|
|
5100
|
+
if (Object.hasOwn(value, "deny")) {
|
|
5101
|
+
const localPolicy: Record<string, unknown> = { deny: value.deny }, policies: Record<string, unknown> = {};
|
|
5102
|
+
policies.local = localPolicy;
|
|
5103
|
+
validateRawConfig(policies, declarations, adapterCatalog);
|
|
5104
|
+
deny = value.deny as string[];
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
const value = { pii: [...new Set([...(config.pii?.paths ?? []), ...pii])], deny: [...new Set([...config.local.deny, ...deny])] };
|
|
5109
|
+
visualPolicyCache = { key, value }; return value;
|
|
5110
|
+
} catch { throw visualOperational("policy"); }
|
|
5111
|
+
};
|
|
5112
|
+
const visualSafeText = (text: string, policy: VisualFilePolicy): boolean => boundedSafe(text) && tasks.publicMetadataText(text)
|
|
5113
|
+
&& !mentionsPrivatePath(text, [], [...policy.pii, ...policy.deny], opts.cwd);
|
|
5114
|
+
const visualPathPublic = (path: string, policy: VisualFilePolicy): boolean => {
|
|
5115
|
+
try {
|
|
5116
|
+
const root = realPath(opts.cwd), typed = resolve(root, path);
|
|
5117
|
+
const publicName = (value: string) => {
|
|
5118
|
+
const name = relative(root, value);
|
|
5119
|
+
return insideRoot(name) && !isHubWritePath(name) && !isDenied(name, policy.deny) && !isDenied(value, policy.deny)
|
|
5120
|
+
&& !matchesPolicy(root, name, policy.pii, true) && visualSafeText(name, policy);
|
|
5121
|
+
};
|
|
5122
|
+
return publicName(typed) && publicName(policyRealPath(typed));
|
|
5123
|
+
} catch { return false; }
|
|
5124
|
+
};
|
|
5125
|
+
const visualTaskPublic = (task?: number, captured?: VisualFilePolicy): boolean => {
|
|
5126
|
+
try {
|
|
5127
|
+
const policy = captured ?? visualFilePolicy();
|
|
5128
|
+
if (task === undefined) return true;
|
|
5129
|
+
const current = board.get(task);
|
|
5130
|
+
return !!current && !tasks.isPii(current) && [...(current.refs.paths ?? []), ...(current.plan?.paths ?? [])].every(path =>
|
|
5131
|
+
visualPathPublic(path, policy));
|
|
5132
|
+
} catch { return false; }
|
|
5133
|
+
};
|
|
5134
|
+
const visualPorts = (policy = visualFilePolicy()): DeliverablePorts => ({ cwd: opts.cwd, deny: policy.deny, private: path =>
|
|
5135
|
+
!visualPathPublic(path, policy) });
|
|
5136
|
+
const visualPublic = (record: VisualRecord | VisualSummary, policy?: VisualFilePolicy): boolean => {
|
|
5137
|
+
try {
|
|
5138
|
+
return !stopping && (record.task !== undefined || record.created >= Date.now() - config.visuals.retention_days * 86_400_000) && !turns.get(record.from)?.private && !contextPii(record.from) && visualTaskPublic(record.task, policy)
|
|
5139
|
+
&& visualSafeText(record.title, policy ?? visualFilePolicy()) && visualSafeText(record.from, policy ?? visualFilePolicy());
|
|
5140
|
+
} catch { return false; }
|
|
5141
|
+
};
|
|
5142
|
+
const visualRecord = (id: unknown, captured?: VisualFilePolicy): VisualRecord | undefined => {
|
|
5143
|
+
if (typeof id !== "string" || !new RegExp(VISUAL_ID_PATTERN).test(id)) return undefined;
|
|
5144
|
+
try {
|
|
5145
|
+
const policy = captured ?? visualFilePolicy(), record = visuals.get(id);
|
|
5146
|
+
if (!record || !visualPublic(record, policy)) return undefined;
|
|
5147
|
+
validateVisualPayload(record.payload, text => visualSafeText(text, policy));
|
|
5148
|
+
const ports = visualPorts(policy);
|
|
5149
|
+
for (const file of record.files) {
|
|
5150
|
+
if (ports.private(file.path)) return undefined;
|
|
5151
|
+
}
|
|
5152
|
+
return record;
|
|
5153
|
+
} catch { return undefined; }
|
|
5154
|
+
};
|
|
5155
|
+
const visualView = (record: VisualRecord) => ({ id: record.id, ...(record.task === undefined ? {} : { task: record.task }), from: record.from,
|
|
5156
|
+
kind: record.kind, title: record.title, payload: record.payload, created: record.created });
|
|
5157
|
+
const visualList = (task?: number): VisualSummary[] => {
|
|
5158
|
+
try { const policy = visualFilePolicy(); return visuals.list({ ...(task === undefined ? {} : { task }), limit: 20 },
|
|
5159
|
+
(record, paths) => visualPublic(record, policy) && paths.every(path => visualPathPublic(path, policy))); }
|
|
5160
|
+
catch { return []; }
|
|
5161
|
+
};
|
|
5162
|
+
async function visualTool(by: PeerId, op: string, a: Record<string, unknown>, piiTurn: boolean): Promise<string> {
|
|
5163
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) throw new PreEffectToolRefusal("visual arguments must be an object; retry with the documented fields");
|
|
5164
|
+
const authority = childToolAuthority.getStore();
|
|
5165
|
+
const actor = (publishing: boolean) => {
|
|
5166
|
+
if (stopping) throw visualOperational("stopping");
|
|
5167
|
+
let current = false;
|
|
5168
|
+
try { current = !authority || authority.by === by && authority.current(); } catch { /* stale authority */ }
|
|
5169
|
+
if (!current) throw visualOperational("authority");
|
|
5170
|
+
if (piiTurn || turns.get(by)?.private || contextPii(by)) throw visualUnavailable();
|
|
5171
|
+
if (!membershipAllowed(by)) throw visualOperational("membership");
|
|
5172
|
+
if (!publishing) return;
|
|
5173
|
+
if (recoveryActive() || bus.isRecoveryHeld) throw visualOperational("recovery");
|
|
5174
|
+
if (readOnlyPeer(by) || !may(by, "remember")) throw visualOperational("role");
|
|
5175
|
+
if (reviewObserverHeld(by) || bus.lastDeliveredTo(by).some(isReviewObserverExcluded)) throw visualOperational("observer");
|
|
5176
|
+
if (!bus.deliveryJournalAvailable()) throw visualOperational("storage");
|
|
5177
|
+
};
|
|
5178
|
+
actor(op !== "hub_visual_show");
|
|
5179
|
+
if (op === "hub_visual_show") {
|
|
5180
|
+
if (Object.keys(a).some(key => key !== "id")) throw new PreEffectToolRefusal("visual show takes only id; retry with a visual id");
|
|
5181
|
+
const record = visualRecord(a.id, visualFilePolicy());
|
|
5182
|
+
if (!record) throw visualUnavailable();
|
|
5183
|
+
return JSON.stringify(visualView(record));
|
|
5184
|
+
}
|
|
5185
|
+
if (Object.keys(a).some(key => !["payload", "task", "to"].includes(key))) throw new PreEffectToolRefusal("visual has an unsupported field; use payload, task and to only");
|
|
5186
|
+
if (a.task !== undefined && (!Number.isSafeInteger(a.task) || Number(a.task) < 1)) throw new PreEffectToolRefusal("visual task must be a positive integer; retry with a task id");
|
|
5187
|
+
const task = a.task as number | undefined, before = task === undefined ? undefined : board.get(task);
|
|
5188
|
+
if (task !== undefined && !before) throw new PreEffectToolRefusal("visual task is unavailable; choose an existing public task and retry");
|
|
5189
|
+
const generation = connectionGeneration(by), turn = turns.get(by);
|
|
5190
|
+
const policyVersion = (filePolicy: VisualFilePolicy) => JSON.stringify({ revision: configRevision, filePolicy, pii: config.pii, deny: config.local.deny, signals: activeRouting().signals, constraints: activeRouting().constraints, mlx: config.mlx, model: activeRouting().local.fixed_model });
|
|
5191
|
+
const initialPolicy = visualFilePolicy(), policy = policyVersion(initialPolicy);
|
|
5192
|
+
const current = (): VisualFilePolicy => {
|
|
5193
|
+
actor(true);
|
|
5194
|
+
const filePolicy = visualFilePolicy();
|
|
5195
|
+
if (policyVersion(filePolicy) !== policy || connectionGeneration(by) !== generation || turns.get(by) !== turn)
|
|
5196
|
+
throw visualOperational("concurrency");
|
|
5197
|
+
if (task !== undefined) {
|
|
5198
|
+
const now = board.get(task);
|
|
5199
|
+
if (!before || !now || now.created !== before.created || now.updated !== before.updated || now.history.length !== before.history.length)
|
|
5200
|
+
throw visualOperational("concurrency");
|
|
5201
|
+
}
|
|
5202
|
+
if (!visualTaskPublic(task, filePolicy)) throw visualUnavailable();
|
|
5203
|
+
return filePolicy;
|
|
5204
|
+
};
|
|
5205
|
+
current();
|
|
5206
|
+
const recipients = typeof a.to === "string" && a.to.length ? [a.to] : a.to;
|
|
5207
|
+
if (a.to !== undefined && (!Array.isArray(recipients) || recipients.some(id => typeof id !== "string" || !id.length)))
|
|
5208
|
+
throw new PreEffectToolRefusal("visual to must be a peer id or array of peer ids; retry with local recipients");
|
|
5209
|
+
const to = a.to === undefined ? [USER] : Array.isArray(recipients) && recipients.length ? [...new Set(recipients as string[])] : bus.knownPeers().filter(id => !parseRemotePeerId(id));
|
|
5210
|
+
if (to.some(id => id !== USER && (!bus.knownPeers().includes(id) || parseRemotePeerId(id)))) throw new PreEffectToolRefusal("visual recipient unavailable; choose a known local peer or user and retry");
|
|
5211
|
+
const id = randomUUID(), priority = a.to === undefined ? "fyi" : "status";
|
|
5212
|
+
const early = newEnvelope(by, `[visual ${id}]`, { priority, to, refs: { visual: id, ...(task === undefined ? {} : { task: String(task) }) } });
|
|
5213
|
+
const preflight = (stub: Envelope, dry: boolean) => {
|
|
5214
|
+
try { bus.checkRemotePublication(stub); } catch { throw new PreEffectToolRefusal("visual delivery unavailable; choose local recipients and retry"); }
|
|
5215
|
+
const offRoster = bus.offRosterRecipients(stub).filter(peer => peer !== USER);
|
|
5216
|
+
const audience = new Set(stub.to?.filter(peer => peer !== by && peer !== USER));
|
|
5217
|
+
if (offRoster.length && offRoster.length === audience.size) throw new PreEffectToolRefusal("visual recipients are off roster; choose an available local peer and retry");
|
|
5218
|
+
const hushed = bus.hushed(stub);
|
|
5219
|
+
if (hushed.length && hushed.length === bus.audience(stub).length) throw new PreEffectToolRefusal("visual delivery is held by the current silence policy; wait for the hold to clear");
|
|
5220
|
+
const effective = offRoster.length ? { ...stub, to: stub.to!.filter(peer => !offRoster.includes(peer)) } : stub;
|
|
5221
|
+
const refused = dry
|
|
5222
|
+
? (by === USER || by === HUB || by === DIGEST || stub.priority === "fyi" ? undefined : limiter.peek(by, effective.to, effective.priority, effective.body))
|
|
5223
|
+
: admit(effective);
|
|
5224
|
+
if (refused) throw new PreEffectToolRefusal("visual delivery is rate limited; wait before retrying");
|
|
5225
|
+
};
|
|
5226
|
+
// No screen, image read, or consuming admission has occurred at this point.
|
|
5227
|
+
preflight(early, true);
|
|
5228
|
+
const input = validateVisualInput(a.payload, text => visualSafeText(text, initialPolicy));
|
|
5229
|
+
if (tasks.screenOn()) {
|
|
5230
|
+
const screen = opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }));
|
|
5231
|
+
const verdict = await screen(JSON.stringify(input)).catch(() => undefined);
|
|
5232
|
+
current();
|
|
5233
|
+
if (verdict?.label === "pii") throw visualUnavailable();
|
|
5234
|
+
if (verdict?.label !== "clear") throw visualOperational("screen");
|
|
5235
|
+
}
|
|
5236
|
+
const beforeRead = current(), resolved = await resolveVisualAssets(input, id, visualPorts(beforeRead));
|
|
5237
|
+
const finalPolicy = current();
|
|
5238
|
+
const payload = validateVisualPayload(resolved.payload, text => visualSafeText(text, finalPolicy));
|
|
5239
|
+
const title = "title" in payload ? payload.title ?? "" : "caption" in payload ? payload.caption ?? "" : "";
|
|
5240
|
+
const record: VisualRecord = { id, ...(task === undefined ? {} : { task }), from: by, kind: payload.kind, title, payload, files: resolved.files, created: Date.now() };
|
|
5241
|
+
if (!visualPublic(record, finalPolicy) || resolved.files.some(file => !visualPathPublic(file.path, finalPolicy))) throw visualUnavailable();
|
|
5242
|
+
const stub = newEnvelope(by, `[visual ${id}] ${payload.kind}: ${sanitize(title)}`, { priority, to, refs: { visual: id, ...(task === undefined ? {} : { task: String(task) }) } });
|
|
5243
|
+
preflight(stub, false); // Recheck after every await; consume shared admission exactly once.
|
|
5244
|
+
if (!recoveryActive() && !bus.isRecoveryHeld) visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task));
|
|
5245
|
+
visuals.insert(record);
|
|
5246
|
+
try {
|
|
5247
|
+
event({ type: "visual", id, kind: payload.kind, bytes: Buffer.byteLength(JSON.stringify(payload)), files: resolved.files.length, ...(task === undefined ? {} : { task }) });
|
|
5248
|
+
bus.publish(stub);
|
|
5249
|
+
} catch { throw new Error(`visual ${id} was stored but its stub delivery failed; inspect the visual before posting again`); }
|
|
5250
|
+
return `visual ${id}`;
|
|
5251
|
+
}
|
|
4744
5252
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
5253
|
+
if (op === "hub_visual" || op === "hub_visual_show") return visualTool(by, op, a, piiTurn);
|
|
4745
5254
|
const reviewTurn = reviewObserverHeld(by) || bus.lastDeliveredTo(by).some(isReviewObserverExcluded);
|
|
4746
5255
|
if (reviewTurn || op === "hub_review" && reviewCaptureExcluded(op, JSON.stringify(a))) progress.clearPeer(by);
|
|
4747
5256
|
if (op === "hub_ask") {
|
|
@@ -5085,6 +5594,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5085
5594
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
5086
5595
|
if (!piiTurn && !reviewTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
5087
5596
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
5597
|
+
// #1279: the reasons of a spec acknowledgement are hub-screened text too, and one key per task keeps a repeat idempotent.
|
|
5598
|
+
const reasons = Array.isArray(a.spec_ack) ? a.spec_ack.flatMap((row: unknown) => typeof (row as { reason?: unknown })?.reason === "string" ? [(row as { reason: string }).reason] : []).join("\n") : "";
|
|
5599
|
+
if (reasons) citeMemory(by, reasons, `spec_ack:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
5088
5600
|
}
|
|
5089
5601
|
// A silent cohort (issue #107): the member that integrates is asked to check its work first; no done yet.
|
|
5090
5602
|
const last = t.history.at(-1);
|
|
@@ -5098,10 +5610,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5098
5610
|
case "hub_sdlc_record":
|
|
5099
5611
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
5100
5612
|
return line(await tasks.recordSdlc(by, a.id, a.receipt));
|
|
5101
|
-
case "hub_review":
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5613
|
+
case "hub_review": {
|
|
5614
|
+
const reviewed = await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings);
|
|
5615
|
+
// #1279: a verdict note that names a brief entry is a citation. The same exclusions as a done summary apply (a PII or review-observer turn, withheld text), and the key binds it to this verdict.
|
|
5616
|
+
const verdict = reviewed.history.findLast(h => h.by === by && (h.event === "approved" || h.event === "changes_requested"));
|
|
5617
|
+
if (!piiTurn && !reviewTurn && !tasks.isPii(reviewed) && verdict && !verdict.withheld && typeof a.note === "string" && a.note && verdict.note?.startsWith(a.note)) {
|
|
5618
|
+
citeMemory(by, a.note, `review:${reviewed.id}:${reviewed.history.indexOf(verdict)}`, [newEnvelope(by, "", { refs: { task: String(reviewed.id) } })]);
|
|
5619
|
+
}
|
|
5620
|
+
return line(reviewed);
|
|
5621
|
+
}
|
|
5622
|
+
case "hub_remember": {
|
|
5623
|
+
const { message: saved, outcome } = await tasks.rememberOutcome(by, a);
|
|
5624
|
+
// #1279: a saved note that names another entry cites it. `tasks.remember` already refused a PII task, a private path and a secret, so only a note it saved gets here.
|
|
5625
|
+
// #802: the structured outcome decides, never the reply wording. Only `not_saved` skips the citation: a candidate note held for a person's review was cited under the old wording test too, and stays so.
|
|
5626
|
+
if (!piiTurn && !reviewTurn && typeof a.text === "string" && a.text && outcome !== "not_saved") {
|
|
5627
|
+
const task = a.task !== undefined && Number.isSafeInteger(Number(a.task)) ? Number(a.task) : undefined;
|
|
5628
|
+
citeMemory(by, a.text, `remember:${task ?? "none"}:${createHash("sha256").update(a.text).digest("hex").slice(0, 16)}`, task !== undefined ? [newEnvelope(by, "", { refs: { task: String(task) } })] : []);
|
|
5629
|
+
}
|
|
5630
|
+
return saved;
|
|
5631
|
+
}
|
|
5105
5632
|
case "hub_checkpoint": {
|
|
5106
5633
|
const summary = String(a.summary ?? "").trim();
|
|
5107
5634
|
if (!summary) throw new Error("summary is required");
|
|
@@ -5509,6 +6036,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5509
6036
|
// Question text lives in hub.db and the envelope to the asking peer only: never a log line, an event, a notice or memory capture.
|
|
5510
6037
|
const questionStore = new QuestionStore(join(opts.stateDir, "hub.db"));
|
|
5511
6038
|
startupCleanup.push(() => questionStore.close());
|
|
6039
|
+
const questionAttributions = new Map<string, TaskAttribution>();
|
|
6040
|
+
/**
|
|
6041
|
+
* The attribution of a question event. It is resolved ONLY when the question opens (`asked`, or `unavailable`, which has no set), by the rule `turn_end` uses, and cached by set. Every later event
|
|
6042
|
+
* of a set repeats the cached answer. A set with no cached answer (the hub restarted while the question was open, which `QuestionDesk.repair` leaves answerable, or the bounded map evicted it)
|
|
6043
|
+
* is `unattributed`: re-resolving it would put the person's answer, with its full latency, on whatever task the peer works on NOW, which is the read-time attribution this exists to prevent.
|
|
6044
|
+
*/
|
|
6045
|
+
const withQuestionAttribution = (e: Extract<HubEvent, { type: "question" }>): HubEvent => {
|
|
6046
|
+
let attribution = questionAttributions.get(e.set);
|
|
6047
|
+
if (!attribution) {
|
|
6048
|
+
if (e.event === "asked" || e.event === "unavailable") {
|
|
6049
|
+
attribution = taskAttribution(e.peer);
|
|
6050
|
+
if (e.set !== "-") { questionAttributions.set(e.set, attribution); if (questionAttributions.size > 512) questionAttributions.delete(questionAttributions.keys().next().value!); }
|
|
6051
|
+
} else attribution = { attribution: "unattributed" };
|
|
6052
|
+
}
|
|
6053
|
+
// A closed question's entry is released; a pending one keeps it for its answer.
|
|
6054
|
+
if (e.event === "answered" || e.event === "superseded" || e.event === "cancelled" || e.event === "expired") questionAttributions.delete(e.set);
|
|
6055
|
+
return { ...e, ...attribution };
|
|
6056
|
+
};
|
|
5512
6057
|
const questionDesk = new QuestionDesk({
|
|
5513
6058
|
store: questionStore, now: () => Date.now(),
|
|
5514
6059
|
waitMs: peer => config.questions.wait_ms[definitionOf(peer)?.kind ?? peer] ?? definitionOf(peer)?.questionWaitMs ?? 45_000,
|
|
@@ -5519,7 +6064,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5519
6064
|
isPrivate: (peer, task, piiTurn) => piiTurn === true || contextPii(peer) || (task !== undefined && (() => { const row = board.get(task); return !row || tasks.isPii(row); })()),
|
|
5520
6065
|
held: () => stopping || recoveryActive() || bus.isRecoveryHeld,
|
|
5521
6066
|
publish: env => { bus.publish(env); },
|
|
5522
|
-
|
|
6067
|
+
// #1278: a question is attributed once, when it is asked, by the rule `turn_end` uses (the task of the delivery the peer is working on, else its single open task, else unattributed), and
|
|
6068
|
+
// every later event of the same set carries that same answer: the person answers minutes later, when the peer's open tasks may have changed. The task a question names for itself is the
|
|
6069
|
+
// peer's claim and is not used. An `unavailable` question has no set and is attributed on the spot.
|
|
6070
|
+
event: e => { event(e.type === "question" ? withQuestionAttribution(e) : e); if (e.type === "question" && e.event !== "asked" && e.event !== "pending" && e.event !== "unavailable") queueMicrotask(() => settleCheck(e.peer)); }, // #1054: a closed question may be the last thing a run waited on
|
|
5523
6071
|
peerKnown: peer => bus.peers.has(peer) || (bus.knownPeers().includes(peer) && membershipAllowed(peer)),
|
|
5524
6072
|
});
|
|
5525
6073
|
// #1053: expiry (`questions.ttl_s`), a changed native session, an envelope a refused publish still owes, and old settled rows. Pushed here, after the desk exists, like the other late timers.
|
|
@@ -5531,6 +6079,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5531
6079
|
startupCleanup.push(() => completionStore.close());
|
|
5532
6080
|
const runs = new RunTracker();
|
|
5533
6081
|
const checkOwners = new Map<PeerId, number>();
|
|
6082
|
+
const nativeBackgroundWaits = new Map<PeerId, ReturnType<typeof setTimeout>>();
|
|
6083
|
+
const nativeBackgroundBackoff = new Map<PeerId, number>();
|
|
5534
6084
|
/**
|
|
5535
6085
|
* A delivery handed to the peer whose turn has not started yet: the bus has already taken it out of its queue and the peer is still idle, so neither the queue nor the busy state
|
|
5536
6086
|
* shows the continuation in that window. Cleared at the turn start or a failed delivery. ponytail: bounded to 10 s, so a delivery that never starts a turn (an adapter that
|
|
@@ -5541,8 +6091,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5541
6091
|
/** A turn of the open run ended in a failure the adapter reported: a closed fact, never its text. */
|
|
5542
6092
|
const runFailed = (peer: PeerId): void => { try { runs.note(peer, { failed: true }); } catch { /* advisory */ } };
|
|
5543
6093
|
const settles = (peer: PeerId): boolean => {
|
|
5544
|
-
// A pull-only
|
|
5545
|
-
return !!definitionOf(peer)?.settle && !peer.includes("@") && bus.peers.get(peer)?.pullOnly !== true;
|
|
6094
|
+
// A pull-only native TUI earns a signal only through its launcher-bound native facts. An unattached pull-only peer still creates no run.
|
|
6095
|
+
return !!definitionOf(peer)?.settle && !peer.includes("@") && (bus.peers.get(peer)?.pullOnly !== true || nativeHookLaunches.current(peer) !== undefined);
|
|
5546
6096
|
};
|
|
5547
6097
|
/** Deliveries queued for the peer that will start another turn: all of them but the hub's own `presence` preface (a `fyi` never reaches the queue). */
|
|
5548
6098
|
// The bus drains its queue into a dispatching journal row before the idle state reaches this tap, so the in-memory queue alone would read empty while a continuation is already on its
|
|
@@ -5562,10 +6112,37 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5562
6112
|
const checksOwnedBy = (peer: PeerId): number => (checkOwners.get(peer) ?? 0) + tasks.checksOwnedBy(peer);
|
|
5563
6113
|
// The peer's OWN adapter state, not the displayed one (a pause shows a busy peer as paused, and its work goes on): an owned child or a continuation the adapter still runs keeps it busy.
|
|
5564
6114
|
const ownedChildrenOf = (peer: PeerId): number => (bus.peers.get(peer)?.state === "busy" ? 1 : 0);
|
|
6115
|
+
/**
|
|
6116
|
+
* #1278: the task a run's completion belongs to, resolved when the run opens (its first turn) by the rule `turn_end` uses and read when it settles, by which time the peer's open tasks have
|
|
6117
|
+
* moved on. In memory only: a run that a restart settles `interrupted` has lost it and reads `unattributed`, which is what is known.
|
|
6118
|
+
*/
|
|
6119
|
+
const runAttributions = new Map<string, TaskAttribution>();
|
|
6120
|
+
const nativeBackgroundOf = (peer: PeerId): number | null | undefined => {
|
|
6121
|
+
const launch = nativeHookLaunches.current(peer), port = definitionOf(peer)?.hooks?.factsHook;
|
|
6122
|
+
if (!launch?.session || !launch.transcript || !port) return undefined;
|
|
6123
|
+
return port.read(launch.session, launch.transcript).background ?? null;
|
|
6124
|
+
};
|
|
6125
|
+
const completeNativeTurn = (peer: PeerId, launch: NativeHookLaunch, completion: NonNullable<NativeFactsTranscript["completion"]>): boolean => {
|
|
6126
|
+
const owner = bus.peers.get(peer);
|
|
6127
|
+
if (!launch.active || !isWsPeer(owner) || owner.state === "offline" || owner.sessionGeneration !== launch.generation || nativeHookLaunches.current(peer) !== launch) return false;
|
|
6128
|
+
launch.completed = completion.id; launch.active = false;
|
|
6129
|
+
turnEnded.set(peer, Date.now()); nativeCompletedAt.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
|
|
6130
|
+
event({ type: "native_turn_end", peer, id: completion.id }); owner.nativeStopped(launch.session!);
|
|
6131
|
+
const run = runs.get(peer);
|
|
6132
|
+
if (completion.reason === "cancelled") {
|
|
6133
|
+
if (run) { completionStore.dropRun(run.runTurn); runs.quietStop(peer); }
|
|
6134
|
+
} else {
|
|
6135
|
+
if (completion.reason === "failed") runFailed(peer);
|
|
6136
|
+
try { if (run) { runs.signalled(peer); settleCheck(peer); } } catch { log(`completion settle for ${peer} failed`); }
|
|
6137
|
+
}
|
|
6138
|
+
return true;
|
|
6139
|
+
};
|
|
5565
6140
|
const completionDone = (peer: PeerId, run: Run, outcome: "done" | "failed" | "limit" | "interrupted"): void => {
|
|
5566
6141
|
const made = completionStore.settle({ peer, runTurn: run.runTurn, lastTurn: run.lastTurn, startedAt: run.startedAt, ...(run.promptEnvelope ? { promptEnvelope: run.promptEnvelope } : {}), ...(run.replyEnvelope ? { replyEnvelope: run.replyEnvelope } : {}), private: run.private }, outcome, Date.now(), `cmp-${randomUUID().replaceAll("-", "").slice(0, 12)}`);
|
|
5567
6142
|
if (!made) return;
|
|
5568
|
-
|
|
6143
|
+
const attribution = runAttributions.get(run.runTurn) ?? { attribution: "unattributed" as const };
|
|
6144
|
+
runAttributions.delete(run.runTurn);
|
|
6145
|
+
event({ type: "completion", id: made.id, peer, outcome, ms: made.settledAt - made.startedAt, ...attribution });
|
|
5569
6146
|
};
|
|
5570
6147
|
/**
|
|
5571
6148
|
* The one settle check. It runs at every point the pending-work facts can change: a turn end, a queue change, a permission or question closing, a check ending. A run settles
|
|
@@ -5573,10 +6150,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5573
6150
|
*/
|
|
5574
6151
|
const settleCheck = (peer: PeerId): void => {
|
|
5575
6152
|
try {
|
|
6153
|
+
const launch = nativeHookLaunches.current(peer), port = definitionOf(peer)?.hooks?.factsHook;
|
|
6154
|
+
if (launch?.active && launch.session && launch.transcript && port) {
|
|
6155
|
+
const completion = port.read(launch.session, launch.transcript).completion;
|
|
6156
|
+
if (completion && completion.index > (launch.baseline ?? -1) && completion.id !== launch.completed) completeNativeTurn(peer, launch, completion);
|
|
6157
|
+
}
|
|
5576
6158
|
const run = runs.get(peer);
|
|
5577
6159
|
if (!run || stopping) return;
|
|
5578
|
-
const
|
|
5579
|
-
|
|
6160
|
+
const nativeBackground = nativeBackgroundOf(peer);
|
|
6161
|
+
const state = settleVerdict({ signal: run.signalled, queuedContinuations: queuedContinuations(peer), openPermissions: openPermissionsOf(peer), openQuestions: questionStore.openCount(peer), ownedChildren: ownedChildrenOf(peer), runningChecks: checksOwnedBy(peer), heldDeliveries: heldDeliveries(peer), nativeBackground });
|
|
6162
|
+
if (state !== "settled") {
|
|
6163
|
+
// Native task notifications are optional observations. Re-read the inventory and final turn until both are settled or the run/launch ends.
|
|
6164
|
+
// ponytail: a quiet peer backs off from 250 ms to 1 s while each read may scan a 16 MiB index and an 8 MiB wire tail. Upgrade: incremental offsets.
|
|
6165
|
+
if ((launch?.active || nativeBackground === null || (nativeBackground ?? 0) > 0) && !nativeBackgroundWaits.has(peer)) {
|
|
6166
|
+
const delay = nativeBackgroundBackoff.get(peer) ?? 250;
|
|
6167
|
+
const timer = setTimeout(() => { nativeBackgroundWaits.delete(peer); settleCheck(peer); }, delay);
|
|
6168
|
+
nativeBackgroundBackoff.set(peer, Math.min(1000, delay * 2));
|
|
6169
|
+
nativeBackgroundWaits.set(peer, timer); timer.unref?.();
|
|
6170
|
+
}
|
|
6171
|
+
return;
|
|
6172
|
+
}
|
|
6173
|
+
const waiting = nativeBackgroundWaits.get(peer);
|
|
6174
|
+
if (waiting) { clearTimeout(waiting); nativeBackgroundWaits.delete(peer); }
|
|
6175
|
+
nativeBackgroundBackoff.delete(peer);
|
|
5580
6176
|
// Persist FIRST and drop the in-memory run only after the store call returned: a write that throws (SQLite busy or locked) leaves the run open, so a later settle check retries it, and
|
|
5581
6177
|
// the open-run row never outlives a run the daemon no longer tracks (a false `interrupted` after a restart, and a peer index that silently refuses the next run).
|
|
5582
6178
|
// A budget pause stays a budget need (ahub budget): the run ends without a completion rather than reading as a result.
|
|
@@ -5886,7 +6482,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5886
6482
|
join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
|
|
5887
6483
|
// Optional native usage: only the instance-fenced explicit Claude session is read.
|
|
5888
6484
|
// IDs persist in telemetry, so a restart/resume never counts the same native message twice.
|
|
5889
|
-
|
|
6485
|
+
startupTimings.mark("services");
|
|
6486
|
+
const claudeNativeUsageSeen = startupSeeds.claudeNativeUsageSeen;
|
|
6487
|
+
startupTimings.mark("transcript_usage_seed");
|
|
6488
|
+
const agyNativeUsageSeen = new Set(startupEvents.filter(e => e.type === "usage" && e.source === "agy_transcript").map(e => (e as { id: string }).id));
|
|
6489
|
+
const agyTurnContext = new WeakMap<object, { hubTurnId?: string; baseline: number; attribution: TaskAttribution }>();
|
|
5890
6490
|
const collectClaudeUsage = () => { for (const peer of channelPeers) {
|
|
5891
6491
|
const session = claudeSession(peer);
|
|
5892
6492
|
if (!session.sessionId || !session.transcriptPath) continue;
|
|
@@ -5922,6 +6522,151 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5922
6522
|
// the newest `older` of them while they are empty, so an upgrade from such a hub verifies the board it was handed; a
|
|
5923
6523
|
// filled one stays in the row and never matches.
|
|
5924
6524
|
const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
|
|
6525
|
+
// Person-owned runtime choices are separate from reload-bound configuration and native serving evidence.
|
|
6526
|
+
const modelBoundaries = new Map<string, { owner: PeerAdapter; token: object; turn?: object; nativeStarted?: true }>();
|
|
6527
|
+
const modelUnsupported = (peer: string): RuntimeModelStatus["unsupported"] | undefined => {
|
|
6528
|
+
const definition = definitionOf(peer), owner = bus.peers.get(peer);
|
|
6529
|
+
if (definition && isFixedBuiltinDefinition(definition) && definition.transport === "acp") {
|
|
6530
|
+
const state = owner ? modelAcpOwners.get(owner)?.modelOverrideState : undefined;
|
|
6531
|
+
if (!definition.modelOverride?.acp || !state?.available) return {
|
|
6532
|
+
reasonCode: "acp_setter_unavailable", kind: definition.kind, ...(state?.version ? { version: state.version } : {}),
|
|
6533
|
+
reason: `unsupported live: ${definition.kind} ${state?.version ?? "unknown"} lacks a setter receipt or compat admission`,
|
|
6534
|
+
nextStep: `restart this peer with ahub restart ${peer}`,
|
|
6535
|
+
};
|
|
6536
|
+
}
|
|
6537
|
+
if (!definition || !isFixedBuiltinDefinition(definition) || !definition.modelOverride) return {
|
|
6538
|
+
reasonCode: "unverified_setter", ...(definition ? { kind: definition.kind } : {}),
|
|
6539
|
+
reason: `unsupported live: ${definition?.kind ?? "retained peer"} (version unverified) has no verified model setter`, nextStep: `restart this peer with ahub restart ${peer}`,
|
|
6540
|
+
};
|
|
6541
|
+
if (owner && !modelManagedOwners.has(owner)) return { reasonCode: "unmanaged_attachment", kind: definition.kind, reason: "unsupported live: this attachment has no verified live model setter", nextStep: `change the native setting or restart this peer with ahub restart ${peer}` };
|
|
6542
|
+
return undefined;
|
|
6543
|
+
};
|
|
6544
|
+
const modelTokenAvailable = (peer: string, model: string): boolean => {
|
|
6545
|
+
if (!definitionOf(peer)?.modelOverride?.acp) return true;
|
|
6546
|
+
const owner = bus.peers.get(peer);
|
|
6547
|
+
return !!owner && modelAcpOwners.get(owner)?.acceptsModelOverride(model) === true;
|
|
6548
|
+
};
|
|
6549
|
+
const modelFallback = (peer: string): { model?: string; source: Exclude<ModelSource, "override"> } => {
|
|
6550
|
+
const declared = declarations[peer]?.model;
|
|
6551
|
+
if (isNativeModelToken(declared)) return { model: declared, source: "instance" };
|
|
6552
|
+
const owner = bus.peers.get(peer), model = owner ? modelLaunchModels.get(owner) : undefined;
|
|
6553
|
+
if (model !== undefined) return { model, source: "launch" };
|
|
6554
|
+
const baseline = owner ? modelAcpOwners.get(owner)?.modelOverrideState.baseline : undefined;
|
|
6555
|
+
return { ...(baseline ? { model: baseline } : {}), source: "native default" };
|
|
6556
|
+
};
|
|
6557
|
+
const registerModelOwner = (owner: PeerAdapter): void => {
|
|
6558
|
+
const definition = definitionOf(owner.id);
|
|
6559
|
+
if (!definition || !isFixedBuiltinDefinition(definition) || !definition.modelOverride) return;
|
|
6560
|
+
if (definition.modelOverride.acp && !modelAcpOwners.get(owner)?.modelOverrideState.available) return;
|
|
6561
|
+
modelManagedOwners.add(owner);
|
|
6562
|
+
try {
|
|
6563
|
+
const launch = owner.recoveryMetadata?.()?.launch;
|
|
6564
|
+
const model = launch && typeof launch === "object" && !Array.isArray(launch) ? (launch as Record<string, unknown>).model : undefined;
|
|
6565
|
+
if (isNativeModelToken(model)) modelLaunchModels.set(owner, model);
|
|
6566
|
+
} catch { /* No explicit launch evidence: leave native default unknown. Never read this hook on a poll. */ }
|
|
6567
|
+
};
|
|
6568
|
+
const runtimeModelRow = (peer: string) => modelLatches.status(peer, bus.peers.get(peer), modelFallback(peer), modelUnsupported(peer));
|
|
6569
|
+
const MODEL_DISPLAY_ROWS_MAX = 512;
|
|
6570
|
+
const runtimeModelRows = (): RuntimeModelStatus[] | undefined => {
|
|
6571
|
+
const ids = new Set<string>();
|
|
6572
|
+
const add = (id: string) => { if (PEER_ID.test(id)) ids.add(id); return ids.size <= MODEL_DISPLAY_ROWS_MAX; };
|
|
6573
|
+
// Stop at the first overflow: existing legacy attachments are not admission-bounded by this display ceiling.
|
|
6574
|
+
for (const id of SUPPORTED_PEERS) if (!add(id)) return undefined;
|
|
6575
|
+
for (const id of configuredIds) if (!add(id)) return undefined;
|
|
6576
|
+
for (const id of extraIds) if (!add(id)) return undefined;
|
|
6577
|
+
for (const peer of restored?.peers ?? []) if (peer.externalInstance && !add(peer.id)) return undefined;
|
|
6578
|
+
for (const id of externalInstanceIds) if (!add(id)) return undefined;
|
|
6579
|
+
for (const id of externalRefusedRows.keys()) if (!add(id)) return undefined;
|
|
6580
|
+
for (const id of bus.peers.keys()) if (!add(id)) return undefined;
|
|
6581
|
+
for (const row of modelOverrides.list()) if (!add(row.peer)) return undefined;
|
|
6582
|
+
return [...ids].sort().map(runtimeModelRow);
|
|
6583
|
+
};
|
|
6584
|
+
const runtimeModelProjection = () => {
|
|
6585
|
+
try { const rows = runtimeModelRows(); if (rows) return { runtimeModels: rows }; }
|
|
6586
|
+
catch { /* Model metadata failure is not whole-daemon status failure. The explicit list keeps its refusal. */ }
|
|
6587
|
+
return { runtimeModels: [] as RuntimeModelStatus[], runtimeModelsUnavailable: true as const };
|
|
6588
|
+
};
|
|
6589
|
+
const modelSelection = (peer: string) => {
|
|
6590
|
+
const owner = bus.peers.get(peer);
|
|
6591
|
+
if (!owner || modelUnsupported(peer)) throw new Error("model selection has no current supported owner; inspect ahub status and restart the peer before retrying");
|
|
6592
|
+
let boundary = modelBoundaries.get(peer);
|
|
6593
|
+
if (!boundary || boundary.owner !== owner) {
|
|
6594
|
+
if (boundary) modelLatches.forget(peer);
|
|
6595
|
+
boundary = { owner, token: {}, ...(turns.get(peer) ? { turn: turns.get(peer) } : {}) };
|
|
6596
|
+
modelBoundaries.set(peer, boundary);
|
|
6597
|
+
}
|
|
6598
|
+
const captured = boundary;
|
|
6599
|
+
const selection = modelLatches.capture(peer, owner, captured.token, () => !stopping && bus.peers.get(peer) === owner
|
|
6600
|
+
&& modelBoundaries.get(peer) === captured && (!captured.turn || turns.get(peer) === captured.turn));
|
|
6601
|
+
return { model: selection.model, applied: (nativeModel?: string) => { selection.applied(nativeModel); if (!stopping) writeStatus(); }, rejected: () => {
|
|
6602
|
+
if (stopping || bus.peers.get(peer) !== owner || modelBoundaries.get(peer) !== captured || captured.nativeStarted
|
|
6603
|
+
|| captured.turn && turns.get(peer) !== captured.turn) return;
|
|
6604
|
+
if (modelLatches.end(peer, captured.token, true)) { modelBoundaries.delete(peer); writeStatus(); }
|
|
6605
|
+
} };
|
|
6606
|
+
};
|
|
6607
|
+
const acpModelSelection = (peer: string) => {
|
|
6608
|
+
if (modelUnsupported(peer)) {
|
|
6609
|
+
if (modelOverrides.get(peer)) throw new Error("retained model override has no current receipt-verified ACP setter; clear it with ahub model clear or restart a supported peer before retrying");
|
|
6610
|
+
return undefined; // An unavailable optional setter must not break ordinary no-override ACP work.
|
|
6611
|
+
}
|
|
6612
|
+
return modelSelection(peer);
|
|
6613
|
+
};
|
|
6614
|
+
type ModelGrant = { source: "cli" | "dashboard"; scope: object | string; current: () => boolean; expiresAt?: number };
|
|
6615
|
+
type ModelPreview = { peer: string; action: "set" | "clear"; model?: string; expiresAt: number; scope: object | string; source: "cli" | "dashboard";
|
|
6616
|
+
revision: string; runtime: string; config: number; owner: PeerAdapter | undefined; generation: string | undefined; afterTurn: string | undefined };
|
|
6617
|
+
const modelPreviews = new Map<string, ModelPreview>();
|
|
6618
|
+
const MODEL_CONFIRM_MS = 60_000, MODEL_PREVIEWS_MAX = 64;
|
|
6619
|
+
const modelFailure = (error: string) => ({ ok: false as const, error });
|
|
6620
|
+
function modelCommand(input: Record<string, unknown>, grant: ModelGrant): Record<string, unknown> {
|
|
6621
|
+
if (!grant.current()) return modelFailure("model changes are person-only and this authority expired; run ahub model in a person's terminal or reopen ahub ui --settings");
|
|
6622
|
+
if (stopping || recoveryActive() || bus.isRecoveryHeld) return modelFailure("recovery or shutdown is holding model overrides; inspect ahub status and retry after release");
|
|
6623
|
+
if (input.op === "list") {
|
|
6624
|
+
const rows = runtimeModelRows();
|
|
6625
|
+
return rows ? { ok: true, models: rows } : modelFailure("model list exceeds its display bound; close unused peer sessions and restart if needed, or clear unused overrides with ahub model clear, then list again");
|
|
6626
|
+
}
|
|
6627
|
+
let preview: ModelPreview;
|
|
6628
|
+
if (input.op === "confirm") {
|
|
6629
|
+
const token = typeof input.token === "string" ? input.token : "", retained = modelPreviews.get(token);
|
|
6630
|
+
if (!retained || retained.scope !== grant.scope || retained.source !== grant.source || retained.expiresAt <= Date.now()) return modelFailure("model confirmation expired or belongs to another session; preview the model change again");
|
|
6631
|
+
modelPreviews.delete(token); preview = retained;
|
|
6632
|
+
if (preview.config !== configRevision || preview.revision !== modelOverrides.revision(preview.peer) || preview.owner !== bus.peers.get(preview.peer)
|
|
6633
|
+
|| preview.runtime !== JSON.stringify(runtimeModelRow(preview.peer)) || preview.generation !== connectionGeneration(preview.peer)
|
|
6634
|
+
|| preview.afterTurn !== turns.get(preview.peer)?.id) return modelFailure("peer, configuration or model choice changed after preview; refresh and preview the change again");
|
|
6635
|
+
} else {
|
|
6636
|
+
const action = input.op === "preview" ? input.action : input.op, peer = input.peer;
|
|
6637
|
+
if ((action !== "set" && action !== "clear") || typeof peer !== "string" || !PEER_ID.test(peer)) return modelFailure("invalid model request; use model set <peer> <model> or model clear <peer>");
|
|
6638
|
+
if (!definitionOf(peer) && !(action === "clear" && modelOverrides.get(peer))) return modelFailure("model peer is unknown; run ahub model list and choose a current or retained peer");
|
|
6639
|
+
if (action === "set" && !isNativeModelToken(input.model)) return modelFailure("invalid native model token; use 1 to 128 ASCII letters, digits or ._:/@+- with an initial letter or digit");
|
|
6640
|
+
const unsupported = modelUnsupported(peer);
|
|
6641
|
+
if (action === "set" && unsupported) return modelFailure(`${unsupported.reason}; ${unsupported.nextStep}`);
|
|
6642
|
+
if (action === "set" && !modelTokenAvailable(peer, input.model as string)) return modelFailure("requested model is not advertised by this receipt-verified ACP session; inspect native model choices and retry");
|
|
6643
|
+
const expiresAt = Math.min(Date.now() + MODEL_CONFIRM_MS, grant.expiresAt ?? Infinity);
|
|
6644
|
+
preview = { peer, action, ...(action === "set" ? { model: input.model as string } : {}), expiresAt, scope: grant.scope, source: grant.source,
|
|
6645
|
+
revision: modelOverrides.revision(peer), runtime: JSON.stringify(runtimeModelRow(peer)), config: configRevision, owner: bus.peers.get(peer), generation: connectionGeneration(peer), afterTurn: turns.get(peer)?.id };
|
|
6646
|
+
if (input.op === "preview") {
|
|
6647
|
+
for (const [token, old] of modelPreviews) if (old.expiresAt <= Date.now()) modelPreviews.delete(token);
|
|
6648
|
+
if (modelPreviews.size >= MODEL_PREVIEWS_MAX) return modelFailure("too many model previews remain open; wait a minute and preview again");
|
|
6649
|
+
const token = randomUUID(); modelPreviews.set(token, preview);
|
|
6650
|
+
return { ok: true, model: runtimeModelRow(peer), confirmation: { token, expiresAt, peer, action, previousModel: modelOverrides.get(peer)?.model ?? null,
|
|
6651
|
+
nextModel: preview.model ?? null, appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) } };
|
|
6652
|
+
}
|
|
6653
|
+
if (grant.source !== "cli") return modelFailure("dashboard model changes require preview and confirmation; preview the change again");
|
|
6654
|
+
}
|
|
6655
|
+
if (!grant.current()) return modelFailure("model authority expired before the write; reopen the person settings session and preview again");
|
|
6656
|
+
const unsupported = modelUnsupported(preview.peer);
|
|
6657
|
+
if (preview.action === "set" && unsupported) return modelFailure(`${unsupported.reason}; ${unsupported.nextStep}`);
|
|
6658
|
+
if (preview.action === "set" && !modelTokenAvailable(preview.peer, preview.model!)) return modelFailure("requested model is not advertised by this receipt-verified ACP session; inspect native model choices and retry");
|
|
6659
|
+
const previousModel = modelOverrides.get(preview.peer)?.model ?? null;
|
|
6660
|
+
if (preview.action === "set") modelOverrides.set(preview.peer, preview.model!); else modelOverrides.clear(preview.peer);
|
|
6661
|
+
const audited = event({ type: "model_change", peer: preview.peer, previousModel, model: preview.model ?? null, by: "person", source: grant.source,
|
|
6662
|
+
appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) });
|
|
6663
|
+
writeStatus();
|
|
6664
|
+
if (!audited) return { ok: false, saved: true, error: "model override was saved but its model_change audit could not be appended; run ahub model list before retrying and repair events.jsonl" };
|
|
6665
|
+
return { ok: true, model: runtimeModelRow(preview.peer), appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) };
|
|
6666
|
+
}
|
|
6667
|
+
const modelCommandSafe = (input: Record<string, unknown>, grant: ModelGrant): Record<string, unknown> => {
|
|
6668
|
+
try { return modelCommand(input, grant); } catch { return modelFailure("model override state could not be read or written; inspect ahub status and hub.db before retrying"); }
|
|
6669
|
+
};
|
|
5925
6670
|
const integrity = (older = 0) => {
|
|
5926
6671
|
const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
5927
6672
|
const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
|
|
@@ -5937,7 +6682,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5937
6682
|
const researchRunners = board.runnerReceiptIntegrity();
|
|
5938
6683
|
const researchEvidence = board.researchEvidenceIntegrity();
|
|
5939
6684
|
const reviewComments = board.reviewCommentsIntegrity();
|
|
5940
|
-
|
|
6685
|
+
const modelState = modelOverrides.integrity();
|
|
6686
|
+
const visualIntegrity = visuals.integrity();
|
|
6687
|
+
if (!Number.isSafeInteger(visualIntegrity.count) || visualIntegrity.count < 0 || !/^[a-f0-9]{64}$/.test(visualIntegrity.digest))
|
|
6688
|
+
throw new Error("visual recovery integrity unavailable; inspect the retained store before recovery");
|
|
6689
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(modelState.count ? { modelOverrides: modelState } : {}), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(researchEvidence.links || researchEvidence.revisions ? { researchEvidence } : {}), ...(reviewComments.comments || reviewComments.packages ? { reviewComments } : {}), ...(visualIntegrity.count ? { visuals: visualIntegrity } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
5941
6690
|
};
|
|
5942
6691
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
5943
6692
|
const integrityAs = (expected: unknown) => {
|
|
@@ -6022,6 +6771,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6022
6771
|
const deliveryFailures = bus.failedDeliveryCounts();
|
|
6023
6772
|
const view = {
|
|
6024
6773
|
...(deliveryFailures ? { deliveryFailures } : {}),
|
|
6774
|
+
...runtimeModelProjection(),
|
|
6025
6775
|
...(queueBackpressure ? { queueBackpressure } : {}),
|
|
6026
6776
|
...(crashLines.length ? { crash: crashLines } : {}),
|
|
6027
6777
|
projectId,
|
|
@@ -6068,7 +6818,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6068
6818
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
6069
6819
|
codexProxyPort: opts.codexProxyPort,
|
|
6070
6820
|
peers: Object.fromEntries(
|
|
6071
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const pressure = queueBackpressure?.peers[id]; const oldestQueuedAt = pressure ? pressure.oldestQueuedAt : summary.oldestQueuedAt; const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; const reviewObserverHoldNote = reviewObserverSessionIdentityHoldNote(id); return [id, { state: bus.stateOf(id), ...(includeAttentionItems && isExternalInstance(id) && !externalRefusedRows.has(id) && retainedExternalBinding(id) ? { externalInstance: retainedExternalBinding(id) } : {}), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(reviewObserverHoldNote ? { reviewObserverHoldNote } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(includeAttentionItems && p?.standbySessions?.length ? { standbySessions: p.standbySessions } : {}), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: ownerIsolation[id] === true, ...(ownerIsolation[id] !== true ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(typeof oldestQueuedAt === "number" ? { oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(id) } : {}), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) ? { ...(p.claiming ? { claiming: true } : {}), ...(definitionOf(id)?.transport === "claude-channel" && p.attached ? { attachMode: p.attachMode, attachEvidence: p.attachEvidence } : {}) } : {}), ...(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 ?? [] } : {}) }]; }),
|
|
6821
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const pressure = queueBackpressure?.peers[id]; const oldestQueuedAt = pressure ? pressure.oldestQueuedAt : summary.oldestQueuedAt; const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; const reviewObserverHoldNote = reviewObserverSessionIdentityHoldNote(id); return [id, { state: bus.stateOf(id), ...(includeAttentionItems && isExternalInstance(id) && !externalRefusedRows.has(id) && retainedExternalBinding(id) ? { externalInstance: retainedExternalBinding(id) } : {}), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(reviewObserverHoldNote ? { reviewObserverHoldNote } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(includeAttentionItems && p?.standbySessions?.length ? { standbySessions: p.standbySessions } : {}), ...(includeAttentionItems && p?.ownerAttachment ? { ownerAttachment: p.ownerAttachment } : {}), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: ownerIsolation[id] === true, ...(ownerIsolation[id] !== true ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(typeof oldestQueuedAt === "number" ? { oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(id) } : {}), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) ? { ...(p.claiming ? { claiming: true } : {}), ...(definitionOf(id)?.transport === "claude-channel" && p.attached ? { attachMode: p.attachMode, attachEvidence: p.attachEvidence } : {}) } : {}), ...(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 ?? [] } : {}) }]; }),
|
|
6072
6822
|
),
|
|
6073
6823
|
taskWip: tasks.wipBreakdown(peerIds),
|
|
6074
6824
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -6460,6 +7210,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6460
7210
|
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
6461
7211
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
6462
7212
|
if (e.t === "state") {
|
|
7213
|
+
if (e.state === "offline") { modelBoundaries.delete(e.peer); modelLatches.forget(e.peer); }
|
|
6463
7214
|
roomCommands.changed();
|
|
6464
7215
|
log(`state ${e.peer} -> ${e.state}`);
|
|
6465
7216
|
event({ type: "state", peer: e.peer, state: e.state });
|
|
@@ -6483,7 +7234,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6483
7234
|
const delivery = pendingDeliveryTask.get(e.peer);
|
|
6484
7235
|
pendingDeliveryTask.delete(e.peer);
|
|
6485
7236
|
const attribution = taskAttribution(e.peer, delivery);
|
|
6486
|
-
const id = `${e.peer}#${runId}.${++turnSeq}
|
|
7237
|
+
const id = `${e.peer}#${runId}.${++turnSeq}`, startedAt = Date.now();
|
|
6487
7238
|
// A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
|
|
6488
7239
|
// gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
|
|
6489
7240
|
const pii = holdsPii(e.peer);
|
|
@@ -6499,7 +7250,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6499
7250
|
writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
|
|
6500
7251
|
} catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
|
|
6501
7252
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
6502
|
-
turns.set(e.peer, { id, capture, start:
|
|
7253
|
+
turns.set(e.peer, { id, capture, start: startedAt, 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 } : {}) });
|
|
7254
|
+
const modelBoundary = modelBoundaries.get(e.peer);
|
|
7255
|
+
if (modelBoundary && modelBoundary.owner === bus.peers.get(e.peer)) modelBoundary.turn = turns.get(e.peer);
|
|
7256
|
+
const supervised = supervisionTurns.get(e.peer);
|
|
7257
|
+
if (supervised?.id.startsWith("supervision-")) supervised.nativeTurn = id;
|
|
6503
7258
|
const pendingReviewInput = pendingReviewObserverInputs.get(e.peer);
|
|
6504
7259
|
if (pendingReviewInput) {
|
|
6505
7260
|
pendingReviewInput.turnId = id; delete pendingReviewInput.awaitingTurn;
|
|
@@ -6520,16 +7275,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6520
7275
|
try {
|
|
6521
7276
|
deliveredAwaitingTurn.delete(e.peer);
|
|
6522
7277
|
if (settles(e.peer)) {
|
|
6523
|
-
const person = pendingPersonDelivery.get(e.peer);
|
|
7278
|
+
const person = pendingPersonDelivery.get(e.peer), nativePerson = pendingNativePerson.delete(e.peer);
|
|
6524
7279
|
pendingPersonDelivery.delete(e.peer);
|
|
6525
|
-
const run = runs.turnStarted({ peer: e.peer, turn: id, at: Date.now(), personOriginated: person !== undefined, ...(person ? { promptEnvelope: person.envelope } : {}), private: person?.private === true || pii || holdsPii(e.peer) });
|
|
6526
|
-
if (run) {
|
|
7280
|
+
const run = runs.turnStarted({ peer: e.peer, turn: id, at: Date.now(), personOriginated: person !== undefined || nativePerson, ...(person ? { promptEnvelope: person.envelope } : {}), private: nativePerson || person?.private === true || pii || holdsPii(e.peer) });
|
|
7281
|
+
if (run) {
|
|
7282
|
+
if (run.runTurn === id) { completionStore.openRun(run); runAttributions.set(id, attribution); } else completionStore.touchRun(run);
|
|
7283
|
+
// A run that never settles in this process would keep its entry; the table only ever holds the open runs, one per settling peer.
|
|
7284
|
+
if (runAttributions.size > 256) runAttributions.delete(runAttributions.keys().next().value!);
|
|
7285
|
+
}
|
|
6527
7286
|
}
|
|
6528
7287
|
} catch { log(`completion run start for ${e.peer} failed; no completion for this turn`); }
|
|
6529
|
-
if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
|
|
7288
|
+
if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer, { id, startedAt, attribution }); } catch { /* counting never costs a turn */ }
|
|
6530
7289
|
} else if (!busy && open) {
|
|
6531
7290
|
collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
|
|
7291
|
+
if (softTokenPeer(e.peer)) try { kimiUsage.turnEnded(e.peer, open.id, Date.now()); } catch { /* counting never costs a turn */ }
|
|
7292
|
+
closeRecordFrame(e.peer); recordInputs.delete(e.peer);
|
|
6532
7293
|
turns.delete(e.peer);
|
|
7294
|
+
modelBoundaries.delete(e.peer); modelLatches.end(e.peer);
|
|
6533
7295
|
let afterTurn: (() => void) | undefined;
|
|
6534
7296
|
let files: number | undefined;
|
|
6535
7297
|
let writeProgress: boolean | undefined;
|
|
@@ -6605,9 +7367,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6605
7367
|
// After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
|
|
6606
7368
|
if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
|
|
6607
7369
|
}
|
|
6608
|
-
// A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn.
|
|
6609
|
-
|
|
6610
|
-
if (definitionOf(e.peer)?.turnEnd !== "native-hook") {
|
|
7370
|
+
// A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn. A launcher-bound pull-only native turn follows the same rule.
|
|
7371
|
+
const nativeFactsTurn = nativeHookLaunches.current(e.peer) !== undefined && bus.peers.get(e.peer)?.pullOnly === true;
|
|
7372
|
+
if (nativeFactsTurn ? (nativeCompletedAt.get(e.peer) ?? -1) >= open.start : definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...open.attribution, preparedRoles: open.preparedRoles, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
|
|
7373
|
+
if (!nativeFactsTurn && definitionOf(e.peer)?.turnEnd !== "native-hook") { // Claude's native turn end is its Stop hook
|
|
6611
7374
|
if ((bus.peers.get(e.peer)?.state ?? e.state) === "idle") queueMicrotask(() => finishSupervisionTurn(e.peer));
|
|
6612
7375
|
turnEnded.set(e.peer, Date.now());
|
|
6613
7376
|
tasks.cohorts.turnEnded(e.peer);
|
|
@@ -6622,7 +7385,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6622
7385
|
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
6623
7386
|
}
|
|
6624
7387
|
}
|
|
6625
|
-
if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
|
|
7388
|
+
if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer, true); kimiUsage.drop(e.peer); }
|
|
6626
7389
|
// #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
|
|
6627
7390
|
// session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
|
|
6628
7391
|
if (e.state === "offline") actionBudgets.revoke(e.peer);
|
|
@@ -6753,6 +7516,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6753
7516
|
const timeoutMs = opts.permissionTimeoutMs ?? approvalTimeoutS * 1000;
|
|
6754
7517
|
const createdAt = Date.now();
|
|
6755
7518
|
const expiresAt = createdAt + timeoutMs;
|
|
7519
|
+
// #1278: the task this request belongs to, resolved once when it opens by the rule `turn_end` and `tokens` use. The settle event reuses it, because the turn can have ended by then and the
|
|
7520
|
+
// peer's open tasks changed. Ids and a closed rule name only; unattributed stays unattributed.
|
|
7521
|
+
const requestAttribution = taskAttribution(req.peer);
|
|
6756
7522
|
const publicTool = typeof req.tool === "string" && /^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(req.tool) && !/\s/.test(req.tool) ? req.tool : undefined;
|
|
6757
7523
|
log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console; cancelled after ${timeoutMs / 1000}s)`);
|
|
6758
7524
|
// The tool name is for the desktop notice only: the console push keeps its shape.
|
|
@@ -6781,7 +7547,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6781
7547
|
if (outcome === "expired") { try { notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`); } catch { /* registry withdrawal already completed */ } }
|
|
6782
7548
|
const latencyMs = Math.max(0, Date.now() - createdAt);
|
|
6783
7549
|
const optionKind = option?.kind === "allow_once" || option?.kind === "allow_always" || option?.kind === "reject_once" || option?.kind === "reject_always" ? option.kind : undefined;
|
|
6784
|
-
try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}) }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
|
|
7550
|
+
try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}), ...requestAttribution }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
|
|
6785
7551
|
try { notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`); } catch { /* no reporting failure strands the caller */ }
|
|
6786
7552
|
for (const c of consoles) if (c.data.tail) { try { c.send(JSON.stringify({ t: "permission_closed", id, peer: req.peer, outcome: outcome === "expired" ? "cancelled" : outcome, ...(outcome === "expired" ? { reason: "expired" } : {}), latencyMs })); } catch { /* another surface can still observe the closure */ } }
|
|
6787
7553
|
resolve({ outcome, ...(option ? { optionId: option.optionId, ...(surface ? { approvalId: id } : {}) } : {}) });
|
|
@@ -6793,7 +7559,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6793
7559
|
timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
|
|
6794
7560
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
6795
7561
|
if (signal?.aborted) { aborted(); return; }
|
|
6796
|
-
try { event({ type: "permission", id, peer: req.peer, event: "requested" }); } catch { /* optional observer */ }
|
|
7562
|
+
try { event({ type: "permission", id, peer: req.peer, event: "requested", ...requestAttribution }); } catch { /* optional observer */ }
|
|
6797
7563
|
for (const c of consoles) if (c.data.tail) { try { c.send(push); } catch { /* next tail gets the registered request */ } }
|
|
6798
7564
|
if (config.approvals.notify) { try { desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`); } catch { /* optional desktop */ } }
|
|
6799
7565
|
});
|
|
@@ -7205,6 +7971,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7205
7971
|
if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
|
|
7206
7972
|
startCurrent?.();
|
|
7207
7973
|
const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ peer, command: config.jcode_cmd, home: instance?.jcode_home ?? config.jcode_home,
|
|
7974
|
+
getModelOverride: () => acpModelSelection(peer),
|
|
7975
|
+
...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
|
|
7208
7976
|
providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
|
|
7209
7977
|
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
7210
7978
|
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
@@ -7225,9 +7993,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7225
7993
|
event({ type: "usage", peer, source: "jcode_acp", id: `jcode:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens,
|
|
7226
7994
|
...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), ...taskAttribution(peer) });
|
|
7227
7995
|
} }) }) as JcodePeer;
|
|
7996
|
+
if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(jcode, jcode);
|
|
7228
7997
|
jcodeLaunches.set(peer, jcode);
|
|
7229
7998
|
recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
|
|
7230
|
-
try { startCurrent?.(); await jcode.start(); startCurrent?.(); await reconcileNativeStart(peer, jcode, startCurrent); }
|
|
7999
|
+
try { startCurrent?.(); await jcode.start(); startCurrent?.(); registerModelOwner(jcode); bindRecordOwner(jcode, executionCwd, () => jcode.nativeSession); await reconcileNativeStart(peer, jcode, startCurrent); }
|
|
7231
8000
|
catch { await jcode.stop().catch(() => {}); if (jcodeLaunches.get(peer) === jcode) jcodeLaunches.delete(peer); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
|
|
7232
8001
|
return { ok: true, policy: "unmanaged" };
|
|
7233
8002
|
}
|
|
@@ -7240,6 +8009,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7240
8009
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
7241
8010
|
actionGate: acpActionGate(peer),
|
|
7242
8011
|
cmd, kind: adapter.kind,
|
|
8012
|
+
getModelOverride: () => acpModelSelection(peer),
|
|
8013
|
+
...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
|
|
7243
8014
|
toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
|
|
7244
8015
|
permissionMode: permissionMode(peer),
|
|
7245
8016
|
...(args.model ? { launchModel: args.model } : {}),
|
|
@@ -7275,10 +8046,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7275
8046
|
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
|
|
7276
8047
|
preamble: sessionRoleContract(peer),
|
|
7277
8048
|
}) }) as AcpPeer;
|
|
8049
|
+
if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(kimi, kimi);
|
|
7278
8050
|
recoveryTaskPreface(peer);
|
|
7279
8051
|
await ensurePreface(peer); startCurrent?.();
|
|
7280
8052
|
try {
|
|
7281
|
-
startCurrent?.(); await kimi.start(); startCurrent?.();
|
|
8053
|
+
startCurrent?.(); await kimi.start(); startCurrent?.(); registerModelOwner(kimi);
|
|
8054
|
+
bindRecordOwner(kimi, executionCwd, () => kimi.nativeSession);
|
|
7282
8055
|
await reconcileNativeStart(peer, kimi, startCurrent);
|
|
7283
8056
|
} catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
7284
8057
|
return { ok: true };
|
|
@@ -7296,6 +8069,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7296
8069
|
const externalLaunchId = externalBinding ? randomUUID() : undefined;
|
|
7297
8070
|
if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
|
|
7298
8071
|
const acpOptions: AcpOptions = {
|
|
8072
|
+
getModelOverride: () => acpModelSelection(peer),
|
|
8073
|
+
...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
|
|
7299
8074
|
toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
|
|
7300
8075
|
onActivity: item => activityRings[peer]?.append(item),
|
|
7301
8076
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
@@ -7328,6 +8103,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7328
8103
|
if (externalBinding && externalLaunchId) externalLaunches.set(peer, { owner, options: acpOptions, launchId: externalLaunchId, binding: externalBinding, workspace: externalWorkspace! });
|
|
7329
8104
|
assertNativeProofPorts(adapter, owner);
|
|
7330
8105
|
if (!isAcpPeer(owner) || owner.id !== peer || owner.deliveryOnly !== true || owner.hubOwned !== true || owner.form !== "headless") throw new Error("ACP factory did not return its bound native transport");
|
|
8106
|
+
if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(owner, owner);
|
|
7331
8107
|
recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
|
|
7332
8108
|
try {
|
|
7333
8109
|
if (externalBinding && (!membershipAllowed(peer) || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || !sameExternalInstanceBinding(externalBinding, captureExternalBinding(peer, args.sessionId)))) throw new Error("external instance binding changed before native spawn");
|
|
@@ -7342,6 +8118,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7342
8118
|
externalSessions.set(peer, receipt.sessionId);
|
|
7343
8119
|
writeExternalInstanceRecord(opts.stateDir, externalBinding, receipt.sessionId);
|
|
7344
8120
|
}
|
|
8121
|
+
registerModelOwner(owner);
|
|
8122
|
+
bindRecordOwner(owner, executionCwd, () => owner.nativeSession);
|
|
7345
8123
|
await reconcileNativeStart(peer, owner, startCurrent);
|
|
7346
8124
|
}
|
|
7347
8125
|
catch (error) {
|
|
@@ -7359,6 +8137,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7359
8137
|
actionBudgets.revoke(peer);
|
|
7360
8138
|
startCurrent?.();
|
|
7361
8139
|
const codex: CodexPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["codex-appserver"]({
|
|
8140
|
+
getModelOverride: () => modelSelection(peer),
|
|
8141
|
+
onNativeModelApplied: model => { if (!stopping && bus.peers.get(peer) === codex && isNativeModelToken(model)) {
|
|
8142
|
+
try { modelLatches.observeNative(peer, codex, model); } catch { modelLatches.forget(peer); }
|
|
8143
|
+
writeStatus();
|
|
8144
|
+
} },
|
|
7362
8145
|
admitAttach: () => membershipAllowed(peer),
|
|
7363
8146
|
permissionMode: permissionMode(peer),
|
|
7364
8147
|
...(declarations[peer] ? { threadSandbox: () => isPlannerOnly(config.roles[peer] ?? []) ? declarations[peer]?.worktree ? "unavailable" as const : "read-only" as const : undefined } : {}),
|
|
@@ -7375,6 +8158,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7375
8158
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
7376
8159
|
onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
|
|
7377
8160
|
onTurn: (native) => {
|
|
8161
|
+
const modelBoundary = modelBoundaries.get(peer);
|
|
8162
|
+
if (modelBoundary && modelBoundary.owner === codex) modelBoundary.nativeStarted = true;
|
|
7378
8163
|
const open = turns.get(peer);
|
|
7379
8164
|
if (open) turnLog?.native(open.id, native);
|
|
7380
8165
|
if (factsOn()) factSession(peer, codex.thread); // a new thread is unverified before its first item
|
|
@@ -7426,10 +8211,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7426
8211
|
watchdogMs: config.watchdog_ms,
|
|
7427
8212
|
log,
|
|
7428
8213
|
}) }) as CodexPeer;
|
|
8214
|
+
registerModelOwner(codex);
|
|
7429
8215
|
recoveryTaskPreface(peer);
|
|
7430
8216
|
await ensurePreface(peer); startCurrent?.();
|
|
7431
8217
|
try {
|
|
7432
8218
|
startCurrent?.(); await codex.start(); startCurrent?.();
|
|
8219
|
+
bindRecordOwner(codex, executionCwd, () => codex.nativeSession);
|
|
7433
8220
|
await reconcileNativeStart(peer, codex, startCurrent);
|
|
7434
8221
|
} catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
7435
8222
|
const proxy = frontendProxyState(peer, codex);
|
|
@@ -7522,6 +8309,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7522
8309
|
const routing = activeRouting();
|
|
7523
8310
|
startCurrent?.();
|
|
7524
8311
|
const pi: PiPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["pi-rpc"]({
|
|
8312
|
+
getModelOverride: () => modelSelection(peer),
|
|
7525
8313
|
cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
|
|
7526
8314
|
model: args.model,
|
|
7527
8315
|
sessionId: args.sessionId, sessionFile: args.sessionFile,
|
|
@@ -7633,9 +8421,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7633
8421
|
},
|
|
7634
8422
|
watchdogMs: config.watchdog_ms, maxSteps: config.pi.max_steps, log,
|
|
7635
8423
|
}) }) as PiPeer;
|
|
8424
|
+
registerModelOwner(pi);
|
|
7636
8425
|
recoveryTaskPreface(peer);
|
|
7637
8426
|
await ensurePreface(peer); startCurrent?.();
|
|
7638
8427
|
executionOwners.set(peer, { owner: pi, cwd: executionCwd });
|
|
8428
|
+
if (pi.mode === "headless") bindRecordOwner(pi, executionCwd, () => pi.nativeSession);
|
|
7639
8429
|
bus.add(pi);
|
|
7640
8430
|
// The start's own error is the one reported; a stop that fails too is logged beside it (#115).
|
|
7641
8431
|
try { startCurrent?.(); await pi.start(); } catch (error) {
|
|
@@ -7702,12 +8492,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7702
8492
|
};
|
|
7703
8493
|
startCurrent?.();
|
|
7704
8494
|
const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
|
|
8495
|
+
getModelOverride: () => modelSelection(peer),
|
|
7705
8496
|
cwd: executionCwd,
|
|
7706
8497
|
omni,
|
|
7707
8498
|
onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
7708
8499
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
|
|
7709
8500
|
...(route ? { route } : {}),
|
|
7710
8501
|
hubRoutes: () => activeRouting().hub_routes ?? {},
|
|
8502
|
+
routeEvidencePublic: boundedSafe,
|
|
8503
|
+
routeDecisionContext: () => ({ pii: contextPii(peer), private: turns.get(peer)?.private === true, excluded: reviewObserverHeld(peer) }),
|
|
7711
8504
|
staySwitch: () => activeRouting(),
|
|
7712
8505
|
onRoute: record => event({ type: "route", peer, ...record }),
|
|
7713
8506
|
onAdvisor: record => event({ type: "advisor", peer, ...record }),
|
|
@@ -7741,11 +8534,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7741
8534
|
maxSteps: config.local.max_steps,
|
|
7742
8535
|
log,
|
|
7743
8536
|
}) }) as LocalPeer;
|
|
8537
|
+
registerModelOwner(local);
|
|
7744
8538
|
localObserverSessionIds.set(local, () => local.observerSessionId);
|
|
7745
8539
|
ownedReviewInputReleasePeers.add(local);
|
|
7746
8540
|
recoveryTaskPreface(peer);
|
|
7747
8541
|
await ensurePreface(peer); startCurrent?.();
|
|
7748
8542
|
executionOwners.set(peer, { owner: local, cwd: executionCwd });
|
|
8543
|
+
bindRecordOwner(local, executionCwd, () => local.observerSessionId);
|
|
7749
8544
|
bus.add(local);
|
|
7750
8545
|
startCurrent?.(); await local.start();
|
|
7751
8546
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
@@ -7764,6 +8559,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7764
8559
|
if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
|
|
7765
8560
|
startCurrent?.();
|
|
7766
8561
|
const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
|
|
8562
|
+
getModelOverride: () => modelSelection(peer),
|
|
7767
8563
|
cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
|
|
7768
8564
|
factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms, ...(declarations[peer]?.model ? { model: declarations[peer].model } : {}),
|
|
7769
8565
|
beforeInput: (context, envs) => beforeOwnedObserverInput(peer, implementer, context, envs),
|
|
@@ -7818,7 +8614,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7818
8614
|
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
7819
8615
|
recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
|
|
7820
8616
|
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
7821
|
-
executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
|
|
8617
|
+
registerModelOwner(implementer); executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
|
|
7822
8618
|
return { ok: true };
|
|
7823
8619
|
} catch (error) { await implementer.stop().catch(() => {}); throw error; }
|
|
7824
8620
|
}
|
|
@@ -7831,6 +8627,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7831
8627
|
const reviewPolicies = new Map<string, string>();
|
|
7832
8628
|
startCurrent?.();
|
|
7833
8629
|
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
|
|
8630
|
+
...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride && !adapter.modelOverride.acp ? { getModelOverride: () => modelSelection(peer) } : {}),
|
|
7834
8631
|
backend, cwd: executionCwd,
|
|
7835
8632
|
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
7836
8633
|
systemPrompt: sessionRoleContract(peer),
|
|
@@ -7897,7 +8694,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7897
8694
|
return { ok: false, error: reason };
|
|
7898
8695
|
}
|
|
7899
8696
|
reviewerUnavailable.delete(peer);
|
|
7900
|
-
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
8697
|
+
registerModelOwner(reviewer); executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
7901
8698
|
reviewerToolObservers.set(peer, (task, tool, refusal) => {
|
|
7902
8699
|
try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
|
|
7903
8700
|
catch { /* Diagnostics never change a host tool's result or authority. */ }
|
|
@@ -8260,6 +9057,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8260
9057
|
const start = input?.view === "memory" && query && query.codeNeighbours === true && typeof query.node === "string" && query.node.startsWith("symbol:") && Buffer.byteLength(query.node) <= 512 ? query.node : undefined;
|
|
8261
9058
|
const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
8262
9059
|
const snapshot = uiSnapshot(after, input, session);
|
|
9060
|
+
if (input?.visual !== undefined) {
|
|
9061
|
+
const record = visualRecord(input.visual);
|
|
9062
|
+
return { ...snapshot, visual: record ? visualView(record) : null };
|
|
9063
|
+
}
|
|
9064
|
+
if (input?.visualTask !== undefined) {
|
|
9065
|
+
const task = input.visualTask;
|
|
9066
|
+
return { ...snapshot, visuals: Number.isSafeInteger(task) && Number(task) > 0 ? visualList(Number(task)) : [] };
|
|
9067
|
+
}
|
|
9068
|
+
if (input?.visualAsset !== undefined) {
|
|
9069
|
+
const match = typeof input.visualAsset === "string" ? /^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}):(before|after)$/.exec(input.visualAsset) : null;
|
|
9070
|
+
const record = match ? visualRecord(match[1]) : undefined;
|
|
9071
|
+
const file = record?.files.find(file => file.asset === input.visualAsset);
|
|
9072
|
+
return { ...snapshot, visualAsset: record && file ? { id: record.id, ...(record.task === undefined ? {} : { task: record.task }), from: record.from, created: record.created, file } : null };
|
|
9073
|
+
}
|
|
8263
9074
|
if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
|
|
8264
9075
|
const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
|
|
8265
9076
|
const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
|
|
@@ -8343,11 +9154,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8343
9154
|
projectId,
|
|
8344
9155
|
instanceId,
|
|
8345
9156
|
attention: attentionStatus.attentionInput,
|
|
9157
|
+
...runtimeModelProjection(),
|
|
8346
9158
|
rooms: attentionStatus.rooms,
|
|
8347
9159
|
pet,
|
|
8348
9160
|
status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo, ownerIsolation: attentionStatus.ownerIsolation, ownerIsolationNotes: attentionStatus.ownerIsolationNotes },
|
|
8349
9161
|
boardCapabilities: { backlog: true },
|
|
8350
9162
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
9163
|
+
visuals: visualList(),
|
|
8351
9164
|
// #1144: source metadata only, including private records; never expose origins or stored text.
|
|
8352
9165
|
sources: sourceRegistry.list().map(({ id, kind, bytes, sha256, added_at }) => ({ id, kind, bytes, hashPrefix: sha256.slice(0, 12), added_at })),
|
|
8353
9166
|
releases: tasks.releaseList(USER),
|
|
@@ -9222,6 +10035,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9222
10035
|
}
|
|
9223
10036
|
case "setting":
|
|
9224
10037
|
return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
|
|
10038
|
+
case "model_preview":
|
|
10039
|
+
case "model_confirm": {
|
|
10040
|
+
const current = () => session?.settings === true && session.liveTest !== true && typeof session.authorityId === "string"
|
|
10041
|
+
&& typeof session.settingsUntil === "number" && session.settingsUntil > Date.now();
|
|
10042
|
+
if (!current()) return modelFailure("model changes require a current person settings session; reopen ahub ui --settings and preview again");
|
|
10043
|
+
return modelCommandSafe(a.action === "model_preview" ? { op: "preview", action: a.modelAction, peer: a.peer, model: a.model } : { op: "confirm", token: a.token },
|
|
10044
|
+
{ source: "dashboard", scope: session!.authorityId!, expiresAt: session!.settingsUntil, current });
|
|
10045
|
+
}
|
|
9225
10046
|
case "setting_undo":
|
|
9226
10047
|
return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
|
|
9227
10048
|
case "setting_preview":
|
|
@@ -9306,6 +10127,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9306
10127
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
9307
10128
|
};
|
|
9308
10129
|
const incompatibleRecoveryTarget = () => {
|
|
10130
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("model_overrides")) && modelOverrides.hasRecords()) return "target lacks model_overrides; retained runtime model choices require a supporting daemon; clear them with ahub model clear or choose a supporting target";
|
|
10131
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("visuals")) {
|
|
10132
|
+
try {
|
|
10133
|
+
if (visuals.hasRecords()) return "target lacks visuals; retained visual records require a supporting daemon; choose a target with visuals";
|
|
10134
|
+
if (rawVisualsPresent(opts.cwd)) return "target lacks visuals; explicit visuals configuration requires a supporting daemon; choose a target with visuals or remove the explicit configuration";
|
|
10135
|
+
} catch { return "visuals compatibility is unknown; the store or configuration could not be read; inspect them or choose a target with visuals"; }
|
|
10136
|
+
}
|
|
9309
10137
|
if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
|
|
9310
10138
|
if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
|
|
9311
10139
|
if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
|
|
@@ -9915,9 +10743,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9915
10743
|
}
|
|
9916
10744
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
9917
10745
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
9918
|
-
if (stopping && !committedSourceProof && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
|
|
10746
|
+
if (stopping && !committedSourceProof && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: msg.t === "task" && (msg.op === "hub_visual" || msg.op === "hub_visual_show") ? visualOperational("stopping").message : "hub is stopping" });
|
|
9919
10747
|
if (c.role === "peer" && c.detachedByPerson) return void reply({ t: msg.t, ok: false, error: "detached by the person; stop using this peer connection and tell the person; inspect ahub status before opening a fresh session" });
|
|
9920
|
-
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
10748
|
+
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: msg.t === "task" && (msg.op === "hub_visual" || msg.op === "hub_visual_show") ? visualOperational("membership").message : `${c.peer} is off roster` });
|
|
9921
10749
|
if (c.role === "peer" && c.peer) {
|
|
9922
10750
|
const currentPeer = bus.peers.get(c.peer);
|
|
9923
10751
|
if (c.standbyId && currentPeer?.ownsSocket?.(sock) !== true) return void reply({ t: msg.t, ok: false, error: "peer is standing by; a person must request takeover" });
|
|
@@ -10288,7 +11116,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10288
11116
|
}
|
|
10289
11117
|
if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
|
|
10290
11118
|
current();
|
|
10291
|
-
if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false })) throw new RecoveryAuthorityError();
|
|
11119
|
+
if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false, ...taskAttribution(c.peer!) })) throw new RecoveryAuthorityError();
|
|
10292
11120
|
current();
|
|
10293
11121
|
issueConductorRecoveryAuthority(opts.stateDir, expected, fresh);
|
|
10294
11122
|
} else {
|
|
@@ -10376,13 +11204,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10376
11204
|
}
|
|
10377
11205
|
case "native_hook_launch": {
|
|
10378
11206
|
if (c.role !== "console" || typeof msg.peer !== "string" || !definitionOf(msg.peer)?.hooks?.factsHook) return void reply({ ok: false, error: "managed native launch requires the person and a supported hook adapter" });
|
|
10379
|
-
if (msg.op === "end")
|
|
11207
|
+
if (msg.op === "end") {
|
|
11208
|
+
const launch = nativeHookLaunches.get(msg.peer, msg.launchId);
|
|
11209
|
+
if (!launch) return void reply({ ok: false });
|
|
11210
|
+
const run = runs.get(msg.peer);
|
|
11211
|
+
if (run) {
|
|
11212
|
+
if (budget.record(msg.peer)) completionStore.dropRun(run.runTurn);
|
|
11213
|
+
else completionDone(msg.peer, run, "interrupted");
|
|
11214
|
+
runs.close(msg.peer);
|
|
11215
|
+
}
|
|
11216
|
+
return void reply({ ok: nativeHookLaunches.end(msg.peer, msg.launchId) });
|
|
11217
|
+
}
|
|
10380
11218
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding native launches" });
|
|
10381
11219
|
if (!membershipAllowed(msg.peer)) return void reply({ ok: false, error: "peer is off roster" });
|
|
10382
11220
|
if (msg.op !== "begin") return void reply({ ok: false, error: "unknown native launch operation" });
|
|
11221
|
+
if (msg.nativeHome !== undefined && (typeof msg.nativeHome !== "string" || !isAbsolute(msg.nativeHome) || msg.nativeHome.length > 4096 || /[\u0000-\u001f\u007f]/.test(msg.nativeHome))) return void reply({ ok: false, error: "native home must be an absolute control-free path" });
|
|
10383
11222
|
const owner = bus.peers.get(msg.peer);
|
|
10384
11223
|
if (owner && owner.state !== "offline") return void reply({ ok: false, error: "native peer is already attached; end it in its terminal first" });
|
|
10385
|
-
try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock).id, instanceId }); }
|
|
11224
|
+
try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock, typeof msg.nativeHome === "string" ? msg.nativeHome : undefined).id, instanceId }); }
|
|
10386
11225
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
10387
11226
|
}
|
|
10388
11227
|
case "native_facts": {
|
|
@@ -10391,49 +11230,151 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10391
11230
|
const launch = nativeHookLaunches.get(peer, msg.nativeLaunchId);
|
|
10392
11231
|
const native = typeof msg.event === "string" ? port.decode(msg.event, msg.payload) : undefined;
|
|
10393
11232
|
if (!launch || !native || !native.workspacePaths.every(root => { try { return realPath(root) === realPath(opts.cwd); } catch { return false; } })) return void reply({ ok: false });
|
|
11233
|
+
if (launch.home && native.transcriptPath !== join(launch.home, "session_index.jsonl")) return void reply({ ok: false });
|
|
10394
11234
|
if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
|
|
10395
11235
|
launch.session = native.sessionId;
|
|
10396
11236
|
const owner = bus.peers.get(peer);
|
|
10397
11237
|
if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
|
|
10398
11238
|
if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
|
|
10399
11239
|
launch.generation = owner.sessionGeneration;
|
|
11240
|
+
launch.transcript = native.transcriptPath;
|
|
11241
|
+
const callStarted = performance.now();
|
|
11242
|
+
const tally = () => tallyHook(peer, callStarted, msg.startedMs);
|
|
10400
11243
|
const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
|
|
10401
11244
|
// Preserve the start baseline at observation, even if native output is flushed before recall allows attach.
|
|
10402
|
-
|
|
11245
|
+
// Skip transcript I/O on PostToolUse, PostInvocation, Stop, and active-turn PreToolUse with no pending fact offers (#1280).
|
|
11246
|
+
const needsBaseline = (native.event === "PreInvocation" || native.event === "PreToolUse") && (!launch.active || native.event === "PreInvocation" && native.invocation === 0)
|
|
11247
|
+
|| native.event === "TurnStarted" || native.event === "Stop" || native.event === "StopFailure" || native.event === "Interrupt";
|
|
11248
|
+
const needsAck = factsOn() && (native.event === "SessionStart" || native.event === "PreInvocation" || native.event === "PreToolUse" && factsFor(peer).pending(peer).length > 0);
|
|
11249
|
+
const since = launch.transcriptPath === native.transcriptPath && launch.transcriptOffset !== undefined ? { sinceOffset: launch.transcriptOffset } : {};
|
|
11250
|
+
const readback: ReturnType<typeof port.read> = needsBaseline
|
|
11251
|
+
? port.read(native.sessionId, native.transcriptPath, since)
|
|
11252
|
+
: needsAck
|
|
11253
|
+
? port.read(native.sessionId, native.transcriptPath, { completion: false, ...since })
|
|
11254
|
+
: { factIds: [] };
|
|
11255
|
+
// ponytail: an unacknowledged fact row followed by >4 MiB of output before the next readback hits the 4 MiB lookback cap in readAgyTranscript.
|
|
11256
|
+
const acknowledge = (read: ReturnType<typeof port.read>, turnCtx?: { hubTurnId?: string; baseline: number; attribution: TaskAttribution }) => {
|
|
11257
|
+
for (const u of read.usage ?? []) {
|
|
11258
|
+
if (agyNativeUsageSeen.has(u.id)) continue;
|
|
11259
|
+
agyNativeUsageSeen.add(u.id);
|
|
11260
|
+
const producedByTurn = !!turnCtx && u.index > turnCtx.baseline;
|
|
11261
|
+
const attr: TaskAttribution = producedByTurn ? turnCtx.attribution : { attribution: "unattributed" };
|
|
11262
|
+
if (u.totalTokens > 0) {
|
|
11263
|
+
if (noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(u.totalTokens));
|
|
11264
|
+
const supervisionTurn = supervisionTurns.get(peer);
|
|
11265
|
+
if (producedByTurn && supervisionTurn && turnCtx.hubTurnId && supervisionTurn.id === turnCtx.hubTurnId) {
|
|
11266
|
+
supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + u.totalTokens;
|
|
11267
|
+
}
|
|
11268
|
+
event({ type: "tokens", peer, n: u.totalTokens, ...attr });
|
|
11269
|
+
const openTurn = turns.get(peer);
|
|
11270
|
+
if (producedByTurn && openTurn && turnCtx.hubTurnId && openTurn.id === turnCtx.hubTurnId) {
|
|
11271
|
+
openTurn.tokens += u.totalTokens;
|
|
11272
|
+
}
|
|
11273
|
+
}
|
|
11274
|
+
event({ type: "usage", peer, source: "agy_transcript", id: u.id, inputTokens: u.inputTokens, outputTokens: u.outputTokens, cacheReadTokens: u.cacheReadTokens, totalTokens: u.totalTokens, ...attr, ...(u.at ? { measuredAt: u.at } : {}) });
|
|
11275
|
+
}
|
|
11276
|
+
if (factsOn()) {
|
|
11277
|
+
factSession(peer, native.sessionId);
|
|
11278
|
+
for (const id of read.factIds) acked(peer, id, "native-hook");
|
|
11279
|
+
}
|
|
11280
|
+
if (read.size !== undefined && read.scannedFrom !== undefined) {
|
|
11281
|
+
const prev = launch.transcriptPath === native.transcriptPath ? (launch.transcriptOffset ?? 0) : 0;
|
|
11282
|
+
if (read.scannedFrom <= prev) {
|
|
11283
|
+
launch.transcriptPath = native.transcriptPath;
|
|
11284
|
+
launch.transcriptOffset = read.size;
|
|
11285
|
+
}
|
|
11286
|
+
}
|
|
11287
|
+
};
|
|
10403
11288
|
const apply = () => {
|
|
10404
11289
|
if (!current()) return void reply({ ok: false });
|
|
10405
|
-
if (
|
|
10406
|
-
|
|
10407
|
-
if (!
|
|
11290
|
+
if (native.event === "TurnStarted") {
|
|
11291
|
+
const started = readback.started;
|
|
11292
|
+
if (!started) return void reply({ ok: false });
|
|
11293
|
+
if (launch.active && launch.turn === started.id) return void reply({ ok: true });
|
|
11294
|
+
if (readback.completion && readback.completion.index >= started.index) return void reply({ ok: true });
|
|
11295
|
+
launch.active = true; launch.turn = started.id; launch.baseline = started.index - 1;
|
|
11296
|
+
pendingNativePerson.delete(peer);
|
|
11297
|
+
if (started.origin === "user") pendingNativePerson.add(peer);
|
|
10408
11298
|
activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
|
|
10409
11299
|
}
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
11300
|
+
owner.bindNativeSession(native.sessionId);
|
|
11301
|
+
const startsTurn = (native.event === "PreInvocation" || native.event === "PreToolUse") && (!launch.active || native.event === "PreInvocation" && native.invocation === 0);
|
|
11302
|
+
if (native.event === "PreInvocation" || native.event === "PreToolUse") {
|
|
11303
|
+
if (startsTurn) {
|
|
11304
|
+
// Credit any leftover rows read at turn start to the preceding turn that produced them, never the new turn.
|
|
11305
|
+
acknowledge(readback, agyTurnContext.get(launch));
|
|
11306
|
+
launch.active = true; launch.turn = crypto.randomUUID(); launch.baseline = readback.completion?.index ?? -1;
|
|
11307
|
+
activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
|
|
11308
|
+
agyTurnContext.set(launch, { hubTurnId: turns.get(peer)?.id, baseline: launch.baseline, attribution: taskAttribution(peer) });
|
|
11309
|
+
} else {
|
|
11310
|
+
activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
|
|
11311
|
+
const activeCtx = { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) };
|
|
11312
|
+
agyTurnContext.set(launch, activeCtx);
|
|
11313
|
+
acknowledge(readback, activeCtx);
|
|
11314
|
+
}
|
|
11315
|
+
} else if (native.event !== "Stop") {
|
|
11316
|
+
const activeCtx = launch.active ? { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) } : agyTurnContext.get(launch);
|
|
11317
|
+
if (launch.active && activeCtx) agyTurnContext.set(launch, activeCtx);
|
|
11318
|
+
acknowledge(readback, activeCtx);
|
|
11319
|
+
}
|
|
11320
|
+
const stopCtx = { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) };
|
|
11321
|
+
agyTurnContext.set(launch, stopCtx);
|
|
11322
|
+
if (native.event === "Stop" && native.terminationReason !== undefined) {
|
|
11323
|
+
if (!launch.active || native.fullyIdle !== true) return void reply({ ok: false });
|
|
11324
|
+
const terminalReason = ({ USER_CANCELED: "user_canceled", ERROR: "error", MAX_INVOCATIONS: "max_invocations", MAX_FORCED_INVOCATIONS: "max_forced_invocations", MAX_TOKEN_BUDGET_EXCEEDED: "max_token_budget_exceeded", TERMINAL_STEP_TYPE: "terminal_step_type", TERMINAL_CUSTOM_HOOK: "terminal_custom_hook" } as const)[native.terminationReason];
|
|
11325
|
+
if (terminalReason) {
|
|
11326
|
+
acknowledge(port.read(native.sessionId, native.transcriptPath, { completion: false, ...since }), stopCtx);
|
|
11327
|
+
launch.active = false; turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
|
|
11328
|
+
tally(); emitHookStats(peer);
|
|
11329
|
+
event({ type: "native_turn_end", peer, reason: terminalReason }); owner.nativeStopped(native.sessionId);
|
|
11330
|
+
// #1054: pull-only agy opens no run (`settles(peer)` is false); if a future native-hook peer does, settle an open run by its closed reason.
|
|
11331
|
+
try {
|
|
11332
|
+
const run = runs.get(peer);
|
|
11333
|
+
if (run) {
|
|
11334
|
+
if (terminalReason === "user_canceled") { completionDone(peer, run, "interrupted"); runs.close(peer); }
|
|
11335
|
+
else { runs.note(peer, terminalReason === "error" || terminalReason === "terminal_step_type" || terminalReason === "terminal_custom_hook" ? { failed: true } : { limit: true }); runs.signalled(peer); settleCheck(peer); }
|
|
11336
|
+
}
|
|
11337
|
+
} catch { log(`completion settle for ${peer} failed`); }
|
|
11338
|
+
return void reply({ ok: true });
|
|
11339
|
+
}
|
|
11340
|
+
if (native.terminationReason !== "NO_TOOL_CALL") return void reply({ ok: false });
|
|
11341
|
+
tally();
|
|
11342
|
+
}
|
|
11343
|
+
if (native.event === "Stop" || native.event === "StopFailure" || native.event === "Interrupt") {
|
|
11344
|
+
if (!launch.active || native.event === "Stop" && native.terminationReason !== undefined && native.fullyIdle !== true) return void reply({ ok: false });
|
|
11345
|
+
// A hook is only the candidate. The final native turn and prompt rows, correlated by id and reason, are the completion evidence.
|
|
11346
|
+
const turn = launch.turn, baseline = launch.baseline ?? -1, expected = native.event === "StopFailure" ? "failed" : native.event === "Interrupt" ? "cancelled" : "completed";
|
|
10415
11347
|
reply({ ok: true, pending: true });
|
|
10416
11348
|
void (async () => {
|
|
10417
11349
|
const deadline = Date.now() + 2000;
|
|
10418
11350
|
let delay = 5;
|
|
10419
|
-
|
|
10420
|
-
// Upgrade: cache unchanged transcript evidence within the wait.
|
|
11351
|
+
let lastStat: { size: number; mtimeMs: number } | undefined;
|
|
10421
11352
|
while (current() && launch.active && launch.turn === turn) {
|
|
10422
|
-
const
|
|
10423
|
-
|
|
10424
|
-
|
|
10425
|
-
|
|
10426
|
-
|
|
10427
|
-
|
|
11353
|
+
const pollSince = launch.transcriptPath === native.transcriptPath && launch.transcriptOffset !== undefined ? { sinceOffset: launch.transcriptOffset } : {};
|
|
11354
|
+
const polled = port.read(native.sessionId, native.transcriptPath, { ...(lastStat ? { unchanged: lastStat } : {}), ...pollSince });
|
|
11355
|
+
if (polled.size !== undefined && polled.mtimeMs !== undefined) lastStat = { size: polled.size, mtimeMs: polled.mtimeMs };
|
|
11356
|
+
acknowledge(polled, stopCtx);
|
|
11357
|
+
const completion = polled.completion;
|
|
11358
|
+
if (completion && completion.index > baseline && completion.id !== launch.completed && (completion.reason ?? "completed") === expected) {
|
|
11359
|
+
completeNativeTurn(peer, launch, completion);
|
|
11360
|
+
emitHookStats(peer);
|
|
10428
11361
|
return;
|
|
10429
11362
|
}
|
|
10430
|
-
if (Date.now() >= deadline) { notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
|
|
11363
|
+
if (Date.now() >= deadline) { settleCheck(peer); emitHookStats(peer); notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
|
|
10431
11364
|
await Bun.sleep(delay); delay = Math.min(50, delay * 2);
|
|
10432
11365
|
}
|
|
10433
11366
|
})().catch(() => log("managed native completion unavailable"));
|
|
10434
11367
|
return;
|
|
10435
11368
|
}
|
|
10436
|
-
|
|
11369
|
+
if (native.event === "TaskStarted") activeAt.set(peer, Date.now());
|
|
11370
|
+
if (native.event === "TaskStarted" || native.event === "Notification") { nativeBackgroundBackoff.delete(peer); settleCheck(peer); }
|
|
11371
|
+
queueMicrotask(tally);
|
|
11372
|
+
const offeredFacts = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
|
|
11373
|
+
// #1280 P2: agy is pull-only, so a model boundary is the one place the hub can tell it mail waits. Counts only, never a body or sender.
|
|
11374
|
+
const modelBoundary = native.event === "PreInvocation" || native.event === "SessionStart";
|
|
11375
|
+
const waiting = bus.queued(peer), important = bus.queuedImportant(peer);
|
|
11376
|
+
const inboxHint = modelBoundary && waiting ? `[agent-hub: ${waiting} message(s) waiting${important ? ` (${important} important)` : ""}; call hub_inbox]` : undefined;
|
|
11377
|
+
const offered = offeredFacts || inboxHint ? { ...(offeredFacts ?? {}), text: [offeredFacts?.text, inboxHint].filter(Boolean).join("\n") } : undefined;
|
|
10437
11378
|
// The decoded native read (agy view_file or read_file, as `Read`) is a peer fact: its target is observed before it runs and the
|
|
10438
11379
|
// view moves after it. Nothing is offered here: agy's hook encodes an offer only on SessionStart/PreInvocation.
|
|
10439
11380
|
if (factsOn() && native.event === "PreToolUse") factsFor(peer).preTool(peer, native.toolUseId, native.tool, native.input);
|
|
@@ -10460,11 +11401,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10460
11401
|
const toolUseId = typeof msg.toolUseId === "string" && msg.toolUseId ? msg.toolUseId.slice(0, 128) : undefined;
|
|
10461
11402
|
const sessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId.slice(0, 128) : undefined;
|
|
10462
11403
|
const callStarted = performance.now();
|
|
10463
|
-
const tally = (peer: PeerId) =>
|
|
10464
|
-
const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
|
|
10465
|
-
const startup = typeof msg.startedMs === "number" && Number.isFinite(msg.startedMs) ? Math.max(0, msg.startedMs) : 0;
|
|
10466
|
-
hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
|
|
10467
|
-
};
|
|
11404
|
+
const tally = (peer: PeerId) => tallyHook(peer, callStarted, msg.startedMs);
|
|
10468
11405
|
try {
|
|
10469
11406
|
const peer = c.peer;
|
|
10470
11407
|
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
@@ -10539,10 +11476,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10539
11476
|
event({ type: "native_turn_end", peer, id });
|
|
10540
11477
|
try { if (runs.get(peer)) { runs.signalled(peer); settleCheck(peer); } } catch { log(`completion settle for ${peer} failed`); } // #1054: the verified native Stop
|
|
10541
11478
|
}
|
|
10542
|
-
turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer); tally(peer);
|
|
10543
|
-
const st = hookStats.get(peer)!;
|
|
10544
|
-
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
10545
|
-
hookStats.delete(peer);
|
|
11479
|
+
turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer); tally(peer); emitHookStats(peer);
|
|
10546
11480
|
if (id && nativePeer) nativePeer.nativeStopped(sessionId!);
|
|
10547
11481
|
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
10548
11482
|
};
|
|
@@ -10937,7 +11871,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10937
11871
|
case "ui_snapshot":
|
|
10938
11872
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
10939
11873
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
10940
|
-
void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || msg.reviewRefresh === true || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(msg.reviewRefresh === true && msg.reviewTask !== undefined ? { reviewTask: msg.reviewTask, reviewRefresh: true } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
|
|
11874
|
+
void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || msg.reviewRefresh === true || dashboardView(msg.view) || msg.visual !== undefined || msg.visualTask !== undefined || msg.visualAsset !== undefined ? { ...(msg.visual !== undefined ? { visual: msg.visual } : {}), ...(msg.visualTask !== undefined ? { visualTask: msg.visualTask } : {}), ...(msg.visualAsset !== undefined ? { visualAsset: msg.visualAsset } : {}), ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(msg.reviewRefresh === true && msg.reviewTask !== undefined ? { reviewTask: msg.reviewTask, reviewRefresh: true } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
|
|
10941
11875
|
return;
|
|
10942
11876
|
case "ui_action":
|
|
10943
11877
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
@@ -10956,6 +11890,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10956
11890
|
if (c.role !== "console") return void reply({ ok: false, error: "autonomy is a person-only command; run ahub autonomy in a person's terminal" });
|
|
10957
11891
|
void autonomyCommand(msg.action, msg.environment, msg.confirmed, msg.confirmation).then(reply, () => reply({ ok: false, error: "project autonomy could not be changed" }));
|
|
10958
11892
|
return;
|
|
11893
|
+
case "model":
|
|
11894
|
+
if (c.role !== "console") return void reply(modelFailure("model overrides are person-only; run ahub model in a person's terminal"));
|
|
11895
|
+
return void reply(modelCommandSafe(msg, { source: "cli", scope: c, current: () => c.authed === true && !c.closed && c.role === "console" && sock.readyState === WebSocket.OPEN }));
|
|
10959
11896
|
case "settings_get":
|
|
10960
11897
|
if (c.role !== "console") {
|
|
10961
11898
|
try { const current = publicReadCurrent(sock); current(); const view = publicSettingsView(); current(); return void reply({ ok: true, ...view }); }
|
|
@@ -11058,7 +11995,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11058
11995
|
// #601: ahub study is the person's. A tools connection (any agent) may use the hub_study_* tools, never these op names, whatever Tasks would answer.
|
|
11059
11996
|
if (String(msg.op).startsWith("study_") && c.role !== "console") return void reply({ t: "task", ok: false, error: "ahub study draft, revise, approve and close are console commands; agents use the hub_study_* tools" });
|
|
11060
11997
|
if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
|
|
11061
|
-
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
11998
|
+
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: msg.op === "hub_visual" || msg.op === "hub_visual_show" ? visualOperational("recovery").message : "recovery is holding mutations" });
|
|
11062
11999
|
if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
|
|
11063
12000
|
// #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
|
|
11064
12001
|
let current: () => void;
|
|
@@ -11322,6 +12259,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11322
12259
|
}
|
|
11323
12260
|
async function stopOnce(): Promise<void> {
|
|
11324
12261
|
stopping = true;
|
|
12262
|
+
kimiUsage.stop();
|
|
11325
12263
|
controlWriteTelemetry?.stop();
|
|
11326
12264
|
const roomsStopped = roomCommands.stop().catch(() => { log("room shutdown failed; inspect ahub room status after restart"); });
|
|
11327
12265
|
tasks.stopQueuePressureRechecks();
|
|
@@ -11385,9 +12323,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11385
12323
|
executionBudget.close();
|
|
11386
12324
|
syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
|
|
11387
12325
|
await syncRun?.catch(() => undefined);
|
|
12326
|
+
for (const peer of recordFrames.keys()) closeRecordFrame(peer); recordInputs.clear(); generatedRecordNotes.clear();
|
|
11388
12327
|
memory.close();
|
|
11389
12328
|
candidates?.close();
|
|
11390
12329
|
memoryStore?.close();
|
|
12330
|
+
modelPreviews.clear(); modelOverrides.close();
|
|
12331
|
+
visuals.close();
|
|
11391
12332
|
board.close();
|
|
11392
12333
|
turnLog?.close();
|
|
11393
12334
|
server.stop(true);
|
|
@@ -11429,6 +12370,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11429
12370
|
// #1054: a run a restart cut short settles `interrupted`, once. Every process of the earlier run is gone by now, and the unique run turn makes repeated restarts yield one completion.
|
|
11430
12371
|
reconstructRuns();
|
|
11431
12372
|
ready = true;
|
|
12373
|
+
startupTimings.mark("ready");
|
|
11432
12374
|
controlWriteTelemetry?.start();
|
|
11433
12375
|
void handoffCatchUp();
|
|
11434
12376
|
// #733: the periodic catch-up and the descriptor watch. Unref'd, cleared with the other intervals at stop; each pass is guarded by `stopping` and never overlaps another.
|
|
@@ -11479,6 +12421,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
11479
12421
|
// #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
|
|
11480
12422
|
void sweepAgentDrift();
|
|
11481
12423
|
// #515b: the loopback ports of the two egress proxies, for a host-only test that connects to the proxy this daemon actually started. Ports only: no list, no authority.
|
|
12424
|
+
startupTimings.mark("post_ready");
|
|
12425
|
+
log(startupTimings.finish());
|
|
11482
12426
|
return { bus, token, port: server.port as number, egressPorts: () => ({ bash: egress?.port, web: webEgress?.port }), bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
11483
12427
|
} finally {
|
|
11484
12428
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|