@staix/agent-hub 0.12.44 → 0.13.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 +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { InboxPolling } from "./inbox-polling.ts";
|
|
2
|
+
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
|
+
import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
|
|
4
|
+
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
|
|
5
|
+
import type { DocEvidence } from "./sdlc-release.ts";
|
|
6
|
+
import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
1
7
|
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
2
8
|
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
3
9
|
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
@@ -18,21 +24,23 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
|
18
24
|
import { loadPetConfig } from "../pet/config.ts";
|
|
19
25
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
20
26
|
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
21
|
-
import {
|
|
27
|
+
import { CodeFacts, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
|
|
28
|
+
import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
|
|
22
29
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
23
30
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
24
31
|
import { ActivityRing } from "./activity.ts";
|
|
25
|
-
import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
|
|
32
|
+
import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
33
|
+
import { VERSION_REFUSAL, describe, isCompatId } from "../adapters/compat.ts";
|
|
26
34
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
27
35
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
28
36
|
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
29
|
-
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
37
|
+
import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
|
|
30
38
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
31
39
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
32
40
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
33
41
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
34
42
|
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";
|
|
35
|
-
import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
43
|
+
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
36
44
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
37
45
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
38
46
|
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
@@ -75,6 +83,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
75
83
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
76
84
|
import { Briefs } from "../memory/brief.ts";
|
|
77
85
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
86
|
+
import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
|
|
78
87
|
import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
79
88
|
import { HUB } from "./envelope.ts";
|
|
80
89
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
@@ -93,7 +102,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
|
|
|
93
102
|
import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
|
|
94
103
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
95
104
|
import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
|
|
96
|
-
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
105
|
+
import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
97
106
|
import { Bus } from "./bus.ts";
|
|
98
107
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
99
108
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -112,7 +121,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
|
112
121
|
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
113
122
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
114
123
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
115
|
-
import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
124
|
+
import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
116
125
|
import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
117
126
|
import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
|
|
118
127
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
|
|
@@ -134,6 +143,7 @@ import { nativeRecall } from "../memory/recall.ts";
|
|
|
134
143
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
135
144
|
|
|
136
145
|
export interface HubConfig {
|
|
146
|
+
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
137
147
|
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
138
148
|
/** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
|
|
139
149
|
instances?: InstancesConfig;
|
|
@@ -143,6 +153,7 @@ export interface HubConfig {
|
|
|
143
153
|
jcode_cmd: string[];
|
|
144
154
|
jcode_home: string;
|
|
145
155
|
jcode_provider_profile: string;
|
|
156
|
+
action_policy?: ActionPolicyConfig;
|
|
146
157
|
permission_modes: Record<string, PermissionMode>;
|
|
147
158
|
protect_instruction_files: boolean;
|
|
148
159
|
/** Loader-owned provenance of requested defaults, before machine-local filtering. */
|
|
@@ -216,6 +227,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
|
|
|
216
227
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
217
228
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
218
229
|
...nativeDefaults.commands,
|
|
230
|
+
action_policy: actionPolicyConfig(),
|
|
219
231
|
permission_modes: {},
|
|
220
232
|
protect_instruction_files: false,
|
|
221
233
|
...nativeDefaults.proxy,
|
|
@@ -261,7 +273,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
261
273
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
262
274
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
263
275
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
264
|
-
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
276
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
265
277
|
|
|
266
278
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
267
279
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
@@ -322,12 +334,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
|
|
|
322
334
|
catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
|
|
323
335
|
}
|
|
324
336
|
|
|
325
|
-
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
|
|
337
|
+
export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
|
|
326
338
|
const captured = layers ?? readInstanceConfigLayers(cwd);
|
|
327
|
-
const instanceConfig = readInstanceConfig(cwd, captured);
|
|
339
|
+
const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
|
|
328
340
|
const instances = instanceConfig.instances;
|
|
329
341
|
const adapterCatalog = instanceConfig.catalog;
|
|
330
342
|
const ignored: string[] = [...instanceConfig.ignored];
|
|
343
|
+
const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
|
|
331
344
|
const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
332
345
|
const files = CONFIG_FILES.flatMap((name) => {
|
|
333
346
|
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
@@ -338,9 +351,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
338
351
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
339
352
|
throw error;
|
|
340
353
|
}
|
|
354
|
+
delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
|
|
341
355
|
delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
|
|
342
356
|
delete file.instances; // Declaration authority was checked against its exact captured origin above.
|
|
343
|
-
peerRosterConfig(file.roster, instances, adapterCatalog);
|
|
357
|
+
peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
|
|
344
358
|
catalogAssignments(file.catalog_roles);
|
|
345
359
|
profileName(file.profile);
|
|
346
360
|
resolveProfile(file.profile, file.profile_overrides);
|
|
@@ -354,6 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
354
368
|
if (file.jcode_home !== undefined && (typeof file.jcode_home !== "string" || !isAbsolute(file.jcode_home))) throw new Error("jcode_home must be an absolute path");
|
|
355
369
|
for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
|
|
356
370
|
if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
|
|
371
|
+
actionPolicyConfig(file.action_policy);
|
|
357
372
|
instructionProtection(file.protect_instruction_files);
|
|
358
373
|
permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
|
|
359
374
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
@@ -412,16 +427,18 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
412
427
|
...DEFAULT_CONFIG,
|
|
413
428
|
...file,
|
|
414
429
|
...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
|
|
430
|
+
...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
|
|
415
431
|
...(instances ? { instances } : {}),
|
|
416
432
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
417
433
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
418
434
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
435
|
+
action_policy: actionPolicyConfig(file.action_policy),
|
|
419
436
|
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
|
|
420
437
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
421
438
|
permission_default_sources: appliedPermissionSources,
|
|
422
439
|
memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
|
|
423
440
|
...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
|
|
424
|
-
roster: peerRosterConfig(file.roster, instances, adapterCatalog),
|
|
441
|
+
roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
|
|
425
442
|
roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
|
|
426
443
|
planner: conductorPolicy.planner,
|
|
427
444
|
conductor: conductorPolicy.conductor,
|
|
@@ -476,9 +493,13 @@ export interface DaemonOptions {
|
|
|
476
493
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
477
494
|
snapshot?: typeof snapshot;
|
|
478
495
|
snapshotBurstMs?: number;
|
|
496
|
+
/** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
|
|
497
|
+
snapshotClock?: () => number;
|
|
479
498
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
480
499
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
481
500
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
501
|
+
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
502
|
+
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
482
503
|
projectId?: string;
|
|
483
504
|
instanceId?: string;
|
|
484
505
|
cwd: string;
|
|
@@ -525,20 +546,49 @@ interface Client {
|
|
|
525
546
|
type Sock = ServerWebSocket<Client>;
|
|
526
547
|
|
|
527
548
|
|
|
549
|
+
/**
|
|
550
|
+
* #713: why `ahub queue resolve` was refused, from a closed list: the bus and the journal throw their own messages, and only these causes are named, never
|
|
551
|
+
* free text from an error (an id or a body would leak). An error that is none of them keeps the old generic sentence.
|
|
552
|
+
*/
|
|
553
|
+
const RESOLUTION_CAUSES: [RegExp, string][] = [
|
|
554
|
+
[/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
|
|
555
|
+
[/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
|
|
556
|
+
[/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
|
|
557
|
+
[/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
|
|
558
|
+
[/already terminal|already resolved/i, "already resolved: the delivery is settled"],
|
|
559
|
+
[/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
|
|
560
|
+
[/queue is full/i, "recipient queue is full: resolve or discard some of its queued work first"],
|
|
561
|
+
[/journal unavailable/i, "delivery journal unavailable: the hub cannot record a resolution right now"],
|
|
562
|
+
];
|
|
563
|
+
export function resolutionRefusal(error: unknown): string {
|
|
564
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
565
|
+
return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
|
|
566
|
+
}
|
|
567
|
+
|
|
528
568
|
export async function startDaemon(opts: DaemonOptions) {
|
|
529
569
|
const projectId = opts.projectId ?? randomUUID();
|
|
530
570
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
531
|
-
const startupCleanup: (() => void)[] = [];
|
|
571
|
+
const startupCleanup: (() => void | Promise<void>)[] = [];
|
|
532
572
|
let ready = false;
|
|
533
573
|
try {
|
|
534
|
-
|
|
574
|
+
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
575
|
+
try { startupLayers = readInstanceConfigLayers(opts.cwd); }
|
|
576
|
+
catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
|
|
577
|
+
const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
|
|
578
|
+
const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
|
|
579
|
+
const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
|
|
580
|
+
const externalIds = new Set(externalAdapters.map(item => item.kind));
|
|
581
|
+
const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
|
|
582
|
+
const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
|
|
583
|
+
: loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
|
|
535
584
|
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
536
585
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
537
586
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
538
587
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
539
|
-
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
|
|
588
|
+
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
|
|
540
589
|
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
541
590
|
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
591
|
+
checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
|
|
542
592
|
const extraIds = Object.keys(declarations);
|
|
543
593
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
544
594
|
const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
|
|
@@ -562,7 +612,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
562
612
|
}
|
|
563
613
|
}
|
|
564
614
|
const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
|
|
565
|
-
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
|
|
615
|
+
roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
|
|
566
616
|
permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
|
|
567
617
|
peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
|
|
568
618
|
...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
|
|
@@ -585,7 +635,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
585
635
|
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
586
636
|
const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
|
|
587
637
|
const SETTINGS = projectSettings(declarations, adapterCatalog);
|
|
588
|
-
|
|
638
|
+
// #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
|
|
639
|
+
// so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
|
|
640
|
+
const staleRosterKey = (key: unknown): string | undefined => {
|
|
641
|
+
const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
|
|
642
|
+
return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
|
|
643
|
+
};
|
|
644
|
+
const settingDef = (key: unknown): SettingDef | string => {
|
|
645
|
+
const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
|
|
646
|
+
return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
|
|
647
|
+
};
|
|
589
648
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
590
649
|
const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
|
|
591
650
|
let rosterChanging: string | undefined;
|
|
@@ -593,7 +652,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
593
652
|
let kimiAdmissionId: string | undefined;
|
|
594
653
|
let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
|
|
595
654
|
const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
|
|
596
|
-
const
|
|
655
|
+
const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
|
|
656
|
+
const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
|
|
597
657
|
// pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
|
|
598
658
|
const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
|
|
599
659
|
conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
|
|
@@ -604,7 +664,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
604
664
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
605
665
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
606
666
|
let runningRouting = currentRouting(opts.cwd, log);
|
|
607
|
-
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
|
|
667
|
+
let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
|
|
608
668
|
const startupSources = structuredClone(acceptedSources.trustLayers);
|
|
609
669
|
const embeddedConfig = structuredClone(config);
|
|
610
670
|
let desiredConfig = structuredClone(config);
|
|
@@ -888,6 +948,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
888
948
|
if (refused) log(`limits: ${env.from}: ${refused}`);
|
|
889
949
|
return refused;
|
|
890
950
|
};
|
|
951
|
+
/**
|
|
952
|
+
* #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
|
|
953
|
+
* exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
|
|
954
|
+
*/
|
|
955
|
+
const jcodeModelSource = (id: string): { modelSource?: string } => {
|
|
956
|
+
const instance = declarations[id];
|
|
957
|
+
if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
|
|
958
|
+
return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
|
|
959
|
+
};
|
|
960
|
+
/**
|
|
961
|
+
* #685 scope update 2: the read-only start preflight for an extra Jcode instance. The declared home must exist as a directory the
|
|
962
|
+
* person owns (the hub never creates it, and reads or writes nothing inside it). The reason names the instance, never the path.
|
|
963
|
+
*/
|
|
964
|
+
const jcodeHomeRefusal = (id: string, home: string): string | undefined => {
|
|
965
|
+
let info: ReturnType<typeof statSync> | undefined;
|
|
966
|
+
try { info = statSync(home); } catch { /* reported below */ }
|
|
967
|
+
if (!info?.isDirectory()) return `${id}: its jcode_home is not an existing directory; the person creates it and signs in to Jcode against it first`;
|
|
968
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return `${id}: its jcode_home is not owned by the person running the hub`;
|
|
969
|
+
return undefined;
|
|
970
|
+
};
|
|
891
971
|
/** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
|
|
892
972
|
const hubStartedAt = Date.now();
|
|
893
973
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
@@ -1093,6 +1173,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1093
1173
|
let modelRelay: ModelRelay | undefined;
|
|
1094
1174
|
const piReceiptLedgers = new Map<string, PiToolReceipts>();
|
|
1095
1175
|
const modelRelays = new Map<string, ModelRelay>();
|
|
1176
|
+
/** #368: the owned Jcode launch of each Jcode peer, from creation (its MCP child connects while `start()` runs, before the bus has the peer). */
|
|
1177
|
+
const jcodeLaunches = new Map<string, JcodePeer>();
|
|
1096
1178
|
const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
|
|
1097
1179
|
const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
|
|
1098
1180
|
const instanceAppPorts = new Map<string, number>();
|
|
@@ -1132,8 +1214,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1132
1214
|
event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
|
|
1133
1215
|
try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
|
|
1134
1216
|
};
|
|
1217
|
+
const inboxPolling = new InboxPolling();
|
|
1135
1218
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1219
|
+
const codeFacts = new CodeFacts();
|
|
1220
|
+
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1136
1221
|
startupCleanup.push(() => board.close());
|
|
1222
|
+
startupCleanup.push(() => codeFacts.close());
|
|
1137
1223
|
const supervision = new SupervisionFeed({
|
|
1138
1224
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1139
1225
|
scope: () => config.conductor?.feed ?? "own",
|
|
@@ -1193,8 +1279,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1193
1279
|
if (!peer || peer.state === "offline") return undefined;
|
|
1194
1280
|
let token = checkConnections.get(peer);
|
|
1195
1281
|
if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1282
|
+
const hasGeneration = "sessionGeneration" in peer;
|
|
1283
|
+
const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
|
|
1284
|
+
if (hasGeneration && native === undefined) return undefined;
|
|
1198
1285
|
return `${instanceId}:${token}:${String(native ?? "")}`;
|
|
1199
1286
|
};
|
|
1200
1287
|
const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
|
|
@@ -1331,9 +1418,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1331
1418
|
projectedReview: task => reviewProjection(task),
|
|
1332
1419
|
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1333
1420
|
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1421
|
+
prepareReviewBrief: async task => {
|
|
1422
|
+
if (tasks.isPii(task) || !task.refs.commit) return;
|
|
1423
|
+
const root = executionRoot(task.owner ?? "");
|
|
1424
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1425
|
+
if ("unavailable" in brief) return;
|
|
1426
|
+
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1427
|
+
reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
|
|
1428
|
+
if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
|
|
1429
|
+
},
|
|
1334
1430
|
reviewBrief: task => {
|
|
1335
1431
|
const root = executionRoot(task.owner ?? "");
|
|
1336
|
-
|
|
1432
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1433
|
+
return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
|
|
1337
1434
|
},
|
|
1338
1435
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1339
1436
|
board,
|
|
@@ -1630,6 +1727,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1630
1727
|
/** What quiescence was judged on, for the log when an integration waits on it. */
|
|
1631
1728
|
const stopEvidence = (peer: PeerId) => `${peer}: turn end ${turnEnded.has(peer) ? new Date(turnEnded.get(peer)!).toISOString() : "none"}, last tool call ${activeAt.has(peer) ? new Date(activeAt.get(peer)!).toISOString() : "none"}, ${bus.peers.get(peer)?.state ?? "offline"}`;
|
|
1632
1729
|
board.onChange = (t, h) => {
|
|
1730
|
+
if (!isClosedTaskState(t.state) && t.state !== "backlog") {
|
|
1731
|
+
if (t.owner) inboxPolling.reset(t.owner);
|
|
1732
|
+
if (t.reviewer) inboxPolling.reset(t.reviewer);
|
|
1733
|
+
}
|
|
1633
1734
|
turnFreeNow = undefined;
|
|
1634
1735
|
scopes = new Map();
|
|
1635
1736
|
progress.prune();
|
|
@@ -1672,8 +1773,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1672
1773
|
const owner = bus.peers.get(peer);
|
|
1673
1774
|
const state = bus.stateOf(peer);
|
|
1674
1775
|
const session = contextSession(peer);
|
|
1675
|
-
const
|
|
1676
|
-
|
|
1776
|
+
const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
|
|
1777
|
+
const generation = generationFenced ? owner.sessionGeneration : undefined;
|
|
1778
|
+
if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
|
|
1677
1779
|
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
1678
1780
|
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
1679
1781
|
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
@@ -1685,7 +1787,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1685
1787
|
: `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
|
|
1686
1788
|
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
1687
1789
|
return new Promise((resolve) => {
|
|
1688
|
-
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!
|
|
1790
|
+
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
|
|
1689
1791
|
const done = (summary: string | undefined) => {
|
|
1690
1792
|
clearTimeout(timer);
|
|
1691
1793
|
checkpointWaits.delete(peer);
|
|
@@ -1729,6 +1831,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1729
1831
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
1730
1832
|
canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1731
1833
|
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
1834
|
+
// #683 U1: pauses, resumes and derived resets go to the event log, ids and numbers only.
|
|
1835
|
+
ledger: entry => event(entry),
|
|
1732
1836
|
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
1733
1837
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
1734
1838
|
resumed: (record) => {
|
|
@@ -1755,8 +1859,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1755
1859
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1756
1860
|
}, accountMembers, accountOf));
|
|
1757
1861
|
budget.setRecoveryHold(recoveryActive());
|
|
1862
|
+
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1863
|
+
// 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
|
|
1864
|
+
// read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
|
|
1865
|
+
budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
|
|
1758
1866
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
1759
1867
|
startupCleanup.push(() => budget.close());
|
|
1868
|
+
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1869
|
+
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
1870
|
+
startupCleanup.push(() => usageRollup.close());
|
|
1871
|
+
const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
|
|
1872
|
+
pollUsageRollup();
|
|
1760
1873
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
1761
1874
|
const runId = Date.now().toString(36);
|
|
1762
1875
|
let turnSeq = 0;
|
|
@@ -1815,7 +1928,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1815
1928
|
if (!snapshotBudget) {
|
|
1816
1929
|
const requested = opts.snapshotBurstMs ?? 250;
|
|
1817
1930
|
const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
|
|
1818
|
-
|
|
1931
|
+
const now = opts.snapshotClock;
|
|
1932
|
+
snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
|
|
1819
1933
|
setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
|
|
1820
1934
|
}
|
|
1821
1935
|
return snapshotBudget;
|
|
@@ -1915,6 +2029,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1915
2029
|
}
|
|
1916
2030
|
const delta = tokenDeltas();
|
|
1917
2031
|
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2032
|
+
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
1918
2033
|
const addTokens = (peer: PeerId, n: number): number => {
|
|
1919
2034
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
1920
2035
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
@@ -1993,6 +2108,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1993
2108
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
1994
2109
|
}, 1000),
|
|
1995
2110
|
setInterval(() => budget.tick(), 30_000),
|
|
2111
|
+
setInterval(pollUsageRollup, 10_000),
|
|
1996
2112
|
setInterval(() => {
|
|
1997
2113
|
if (!ready || !memoryBinding) return;
|
|
1998
2114
|
const before = memoryProblem;
|
|
@@ -2309,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2309
2425
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2310
2426
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2311
2427
|
case "hub_review":
|
|
2312
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2428
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2313
2429
|
case "hub_remember":
|
|
2314
2430
|
return tasks.remember(by, a);
|
|
2315
2431
|
case "hub_checkpoint": {
|
|
@@ -2383,7 +2499,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2383
2499
|
}
|
|
2384
2500
|
case "task_show": {
|
|
2385
2501
|
const task = board.get(Number(a.id));
|
|
2386
|
-
|
|
2502
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2503
|
+
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2504
|
+
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2505
|
+
return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2387
2506
|
}
|
|
2388
2507
|
case "task_assign":
|
|
2389
2508
|
case "task_assign_checked": {
|
|
@@ -2467,6 +2586,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2467
2586
|
else delete defaultSources[peer];
|
|
2468
2587
|
}
|
|
2469
2588
|
};
|
|
2589
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
|
|
2470
2590
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2471
2591
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2472
2592
|
const unattendedPeers = new Set<string>();
|
|
@@ -2475,6 +2595,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2475
2595
|
const claudePermissionGenerations = new Map<string, string>();
|
|
2476
2596
|
const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
|
|
2477
2597
|
let stopping = false;
|
|
2598
|
+
const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
|
|
2599
|
+
docs: opts.releaseDocEvidence,
|
|
2600
|
+
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
2601
|
+
current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
|
|
2602
|
+
record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
|
|
2603
|
+
});
|
|
2478
2604
|
const terminalReads = terminalOwnerReads();
|
|
2479
2605
|
const terminalLaunches = new TerminalLaunches({
|
|
2480
2606
|
owner: row => {
|
|
@@ -2684,13 +2810,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2684
2810
|
};
|
|
2685
2811
|
const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
|
|
2686
2812
|
if (readOnlyPeer(id)) return "read-only";
|
|
2687
|
-
if (isAcpPeer(peer) && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2688
|
-
if (starting.has(id) && (isAcpPeer(peer) || isCodexPeer(peer))) return peer.getPermissionMode();
|
|
2689
2813
|
if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
|
|
2690
2814
|
const launch = claudePermissionLaunch(id);
|
|
2691
2815
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2692
2816
|
}
|
|
2693
|
-
|
|
2817
|
+
const native = peer?.permissionModeState;
|
|
2818
|
+
// An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
|
|
2819
|
+
if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
|
|
2820
|
+
return native ?? permissionMode(id);
|
|
2694
2821
|
};
|
|
2695
2822
|
const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
|
|
2696
2823
|
const note = definitionOf(id)?.permission.unmanagedStatusNote;
|
|
@@ -2745,6 +2872,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2745
2872
|
protocol: PROTOCOL,
|
|
2746
2873
|
features: DAEMON_FEATURES,
|
|
2747
2874
|
configuredAdapters: configuredIds,
|
|
2875
|
+
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
2876
|
+
...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
|
|
2748
2877
|
startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
|
|
2749
2878
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2750
2879
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
@@ -2756,10 +2885,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2756
2885
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2757
2886
|
codexProxyPort: opts.codexProxyPort,
|
|
2758
2887
|
peers: Object.fromEntries(
|
|
2759
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2888
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2760
2889
|
),
|
|
2761
2890
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2762
2891
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2892
|
+
actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
|
|
2763
2893
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2764
2894
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2765
2895
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -3156,7 +3286,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3156
3286
|
if (!end.head) throw new Error("end HEAD evidence is unavailable");
|
|
3157
3287
|
const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
|
|
3158
3288
|
if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
|
|
3159
|
-
const checkBudget = () => { if (end.budget &&
|
|
3289
|
+
const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
3160
3290
|
const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
|
|
3161
3291
|
for (const path of dirtyPaths) {
|
|
3162
3292
|
checkBudget();
|
|
@@ -3674,12 +3804,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3674
3804
|
}
|
|
3675
3805
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3676
3806
|
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
|
|
3677
|
-
|
|
3678
|
-
|
|
3807
|
+
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
3808
|
+
const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
|
|
3809
|
+
if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
|
|
3810
|
+
if (jcodeVerdict.note) log(jcodeVerdict.note);
|
|
3811
|
+
// #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
|
|
3812
|
+
const instance = declarations[peer];
|
|
3813
|
+
// #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
|
|
3814
|
+
// written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
|
|
3815
|
+
if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
|
|
3816
|
+
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,
|
|
3817
|
+
providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
|
|
3818
|
+
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
3819
|
+
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
3679
3820
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
|
|
3821
|
+
jcodeLaunches.set(peer, jcode);
|
|
3680
3822
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3681
3823
|
try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
|
|
3682
|
-
catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
|
|
3824
|
+
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)"); }
|
|
3683
3825
|
return { ok: true, policy: "unmanaged" };
|
|
3684
3826
|
}
|
|
3685
3827
|
if (adapter?.start.acpCommand) {
|
|
@@ -3740,9 +3882,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3740
3882
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3741
3883
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
3742
3884
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3885
|
+
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3743
3886
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
3744
3887
|
cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
|
|
3745
|
-
autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3888
|
+
autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
|
|
3746
3889
|
log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
|
|
3747
3890
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
3748
3891
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
@@ -3755,7 +3898,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3755
3898
|
mcpServers,
|
|
3756
3899
|
preamble: sessionRoleContract(peer),
|
|
3757
3900
|
} } });
|
|
3758
|
-
if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
|
|
3901
|
+
if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
|
|
3759
3902
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3760
3903
|
try { await owner.start(); await reconcileNativeStart(peer, owner); }
|
|
3761
3904
|
catch (error) {
|
|
@@ -3763,7 +3906,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3763
3906
|
if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
|
|
3764
3907
|
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
3765
3908
|
if (error instanceof QwenIdentityError) throw error;
|
|
3766
|
-
throw new Error(
|
|
3909
|
+
throw new Error(`${adapter.displayName} could not reach its provider or complete its startup handshake; verify the provider connection and native CLI${isCompatId(adapter.kind) ? ` (${describe(adapter.kind)})` : ""}; see ahub agents (details withheld)`);
|
|
3767
3910
|
}
|
|
3768
3911
|
return { ok: true };
|
|
3769
3912
|
}
|
|
@@ -3774,6 +3917,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3774
3917
|
admitAttach: () => membershipAllowed(peer),
|
|
3775
3918
|
permissionMode: permissionMode(peer),
|
|
3776
3919
|
onTokens: (added) => void addTokens(peer, added),
|
|
3920
|
+
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
3921
|
+
// the id names the hub run, peer and a per-run sequence, so it is unique across restarts. It never carries the native thread: usage telemetry has no session id (docs/events.md).
|
|
3922
|
+
onTokenBreakdown: delta => {
|
|
3923
|
+
event({ type: "usage", peer, source: "codex_appserver", id: `codex:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: delta.input, cacheReadTokens: delta.cachedInput,
|
|
3924
|
+
outputTokens: delta.output, reasoningTokens: delta.reasoning, totalTokens: delta.total, ...taskAttribution(peer) });
|
|
3925
|
+
},
|
|
3777
3926
|
contextReader: adapter.budget?.context,
|
|
3778
3927
|
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3779
3928
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
@@ -4121,7 +4270,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4121
4270
|
try { await reviewer.start(); }
|
|
4122
4271
|
catch (error) {
|
|
4123
4272
|
await reviewer.stop().catch(() => {});
|
|
4124
|
-
|
|
4273
|
+
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4274
|
+
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4125
4275
|
: "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
|
|
4126
4276
|
reviewerUnavailable.set(peer, reason);
|
|
4127
4277
|
return { ok: false, error: reason };
|
|
@@ -4339,7 +4489,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4339
4489
|
tasks: boardTasks.map((task) => {
|
|
4340
4490
|
const view = tasks.publicView(task);
|
|
4341
4491
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4342
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4492
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4343
4493
|
}),
|
|
4344
4494
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4345
4495
|
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
@@ -4398,12 +4548,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4398
4548
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4399
4549
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4400
4550
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4401
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer =>
|
|
4551
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
4402
4552
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4403
4553
|
}
|
|
4404
4554
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
4405
|
-
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
|
|
4406
|
-
validateEffectiveConfig(fileConfig);
|
|
4555
|
+
const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
4556
|
+
validateEffectiveConfig(fileConfig, adapterCatalog);
|
|
4407
4557
|
const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
|
|
4408
4558
|
const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
|
|
4409
4559
|
const previews: { key: string; before: string; after: string; effect?: string }[] = [];
|
|
@@ -4454,7 +4604,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4454
4604
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4455
4605
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4456
4606
|
}
|
|
4457
|
-
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
4607
|
+
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
4458
4608
|
const decision = authorizeConfigLeaf(input);
|
|
4459
4609
|
if (!decision.allowed) {
|
|
4460
4610
|
const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
|
|
@@ -4500,7 +4650,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4500
4650
|
const running = structuredClone({ ...config, roles: policyRoles });
|
|
4501
4651
|
for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
|
|
4502
4652
|
running.retired = fileConfig.retired;
|
|
4503
|
-
validateEffectiveConfig(running);
|
|
4653
|
+
validateEffectiveConfig(running, adapterCatalog);
|
|
4504
4654
|
conductorPeer(running.roles);
|
|
4505
4655
|
for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
|
|
4506
4656
|
const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
|
|
@@ -4530,7 +4680,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4530
4680
|
const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
|
|
4531
4681
|
texts["config.json"] = next["config.json"];
|
|
4532
4682
|
texts["config.local.json"] = next["config.local.json"];
|
|
4533
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4683
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4534
4684
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4535
4685
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4536
4686
|
return prepared.fileConfig;
|
|
@@ -4538,7 +4688,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4538
4688
|
function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
|
|
4539
4689
|
const texts = configSourceTexts(opts.cwd);
|
|
4540
4690
|
texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
|
|
4541
|
-
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
|
|
4691
|
+
const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
|
|
4542
4692
|
const refusal = prepared.rejected.find(item => item.key === scope);
|
|
4543
4693
|
if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
|
|
4544
4694
|
}
|
|
@@ -4555,7 +4705,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4555
4705
|
const texts = configSourceTexts(opts.cwd);
|
|
4556
4706
|
fingerprint = configFingerprint(texts);
|
|
4557
4707
|
if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
|
|
4558
|
-
const sources = parseConfigSources(texts, undefined, opts.cwd);
|
|
4708
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
4559
4709
|
const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
|
|
4560
4710
|
if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
|
|
4561
4711
|
if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
|
|
@@ -4570,6 +4720,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4570
4720
|
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
4571
4721
|
putConfig(config, change.key, change.after);
|
|
4572
4722
|
}
|
|
4723
|
+
if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
|
|
4573
4724
|
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
4574
4725
|
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4575
4726
|
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
@@ -4622,7 +4773,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4622
4773
|
const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
|
|
4623
4774
|
if (storedConfigCache?.stamp !== stamp) {
|
|
4624
4775
|
let loaded: HubConfig | undefined;
|
|
4625
|
-
try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
|
|
4776
|
+
try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
|
|
4626
4777
|
storedConfigCache = { stamp, config: loaded };
|
|
4627
4778
|
}
|
|
4628
4779
|
return storedConfigCache.config;
|
|
@@ -4645,7 +4796,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4645
4796
|
const rows = settingRows({
|
|
4646
4797
|
instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
|
|
4647
4798
|
running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
|
|
4648
|
-
next: stored ? (path) => path[0] === "roster" ?
|
|
4799
|
+
next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
|
|
4649
4800
|
roles: roleState,
|
|
4650
4801
|
mode: (peer) => {
|
|
4651
4802
|
const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
|
|
@@ -4698,10 +4849,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4698
4849
|
if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
|
|
4699
4850
|
const peer = def.path[1]!;
|
|
4700
4851
|
let saved = false;
|
|
4852
|
+
// #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
|
|
4853
|
+
const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
|
|
4701
4854
|
const terminalNotice = retainedTerminalNotice(peer);
|
|
4702
4855
|
let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
|
|
4703
4856
|
try {
|
|
4704
|
-
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after =
|
|
4857
|
+
const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4705
4858
|
if (!after) {
|
|
4706
4859
|
const refusal = rosterRemovalRefusal(peer, now);
|
|
4707
4860
|
if (refusal) return { ok: false, error: refusal };
|
|
@@ -4715,7 +4868,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4715
4868
|
config.roster = next;
|
|
4716
4869
|
settlePlanner(); // #455: an automatic planner follows the roster
|
|
4717
4870
|
saved = true;
|
|
4718
|
-
recordSetting(def.key, before, after, source, undo, reason);
|
|
4871
|
+
recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
|
|
4719
4872
|
bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
|
|
4720
4873
|
if (!after) {
|
|
4721
4874
|
for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
|
|
@@ -4740,6 +4893,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4740
4893
|
const reloaded = await reloadConfig(authority, `roster.${peer}`);
|
|
4741
4894
|
if (reloaded.ok !== true) return { ...reloaded, saved: true };
|
|
4742
4895
|
writeStatus();
|
|
4896
|
+
if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
|
|
4743
4897
|
const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
|
|
4744
4898
|
return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
|
|
4745
4899
|
} catch (error) {
|
|
@@ -4752,6 +4906,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4752
4906
|
const def = settingDef(key);
|
|
4753
4907
|
if (typeof def === "string") return { error: def };
|
|
4754
4908
|
const routing = activeRouting();
|
|
4909
|
+
if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
|
|
4910
|
+
// A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
|
|
4911
|
+
if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
|
|
4912
|
+
// Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
|
|
4913
|
+
const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
|
|
4914
|
+
if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
|
|
4755
4915
|
try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
|
|
4756
4916
|
catch (error) { return { error: (error as Error).message }; }
|
|
4757
4917
|
}
|
|
@@ -4848,9 +5008,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4848
5008
|
const input = settingInput(key, raw);
|
|
4849
5009
|
if ("error" in input) return { ok: false, error: input.error };
|
|
4850
5010
|
const { def, value } = input;
|
|
5011
|
+
if (def.group === "Roster" && staleRosterKey(def.key)) {
|
|
5012
|
+
return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
|
|
5013
|
+
}
|
|
4851
5014
|
if (def.group === "Roster") {
|
|
4852
5015
|
try {
|
|
4853
|
-
const peer = def.path[1]!, next = rosterCandidate(def, value), on =
|
|
5016
|
+
const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
|
|
4854
5017
|
return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
|
|
4855
5018
|
} catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
|
|
4856
5019
|
}
|
|
@@ -4891,7 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4891
5054
|
if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
|
|
4892
5055
|
const liveIssues = a.liveIssues ?? [];
|
|
4893
5056
|
if (typeof a.repo !== "string" || !Array.isArray(liveIssues) || liveIssues.length > 32) return bad;
|
|
4894
|
-
const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : await tasks.releaseReadiness(a.repo, liveIssues);
|
|
5057
|
+
const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
|
|
4895
5058
|
return { ok: true, report };
|
|
4896
5059
|
}
|
|
4897
5060
|
switch (a.action) {
|
|
@@ -5009,6 +5172,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5009
5172
|
const incompatibleRecoveryTarget = () => {
|
|
5010
5173
|
const cancelled = incompatibleCancellationTarget();
|
|
5011
5174
|
if (cancelled) return cancelled;
|
|
5175
|
+
if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
|
|
5012
5176
|
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
5013
5177
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
5014
5178
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
@@ -5171,8 +5335,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5171
5335
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
5172
5336
|
}
|
|
5173
5337
|
const from = permissionMode(peer);
|
|
5338
|
+
const runtime: PeerAdapter = owner;
|
|
5339
|
+
const nativeMode = runtime.permissionModeState;
|
|
5340
|
+
if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
|
|
5174
5341
|
try {
|
|
5175
|
-
if (
|
|
5342
|
+
if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
|
|
5176
5343
|
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
|
5177
5344
|
if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
|
|
5178
5345
|
permissionModes.set(peer, mode);
|
|
@@ -5264,6 +5431,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5264
5431
|
c.peer = String(msg.peer ?? "");
|
|
5265
5432
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5266
5433
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5434
|
+
// #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
|
|
5435
|
+
// peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
|
|
5436
|
+
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
5437
|
+
const owner = jcodeLaunches.get(c.peer);
|
|
5438
|
+
if (!owner || !owner.ownsLaunch(msg.launchId)) return sock.close(4403, "this process is not an owned launch of that peer; start it with ahub jcode");
|
|
5439
|
+
}
|
|
5267
5440
|
} else {
|
|
5268
5441
|
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5269
5442
|
if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
|
|
@@ -5327,6 +5500,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5327
5500
|
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
5328
5501
|
return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
|
|
5329
5502
|
}
|
|
5503
|
+
if (msg.op === "discard") {
|
|
5504
|
+
// #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
|
|
5505
|
+
// through the same `resolveDelivery` as one by one, each with the reason, so the journal records each as a `resolve ... discard`. Push peers' queues are never touched
|
|
5506
|
+
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
5507
|
+
if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
|
|
5508
|
+
const state = msg.state === undefined ? "queued" : msg.state;
|
|
5509
|
+
if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
5510
|
+
const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
|
|
5511
|
+
if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
|
|
5512
|
+
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
5513
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
5514
|
+
let discarded = 0; const refused: Record<string, number> = {};
|
|
5515
|
+
for (const row of rows) {
|
|
5516
|
+
// A queued row with no journal record of its own carries the journal's global revision, which every discard moves: read the row's current revision just before
|
|
5517
|
+
// discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
|
|
5518
|
+
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
5519
|
+
try {
|
|
5520
|
+
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
5521
|
+
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
|
|
5522
|
+
}
|
|
5523
|
+
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
5524
|
+
}
|
|
5525
|
+
writeStatus();
|
|
5526
|
+
return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
5527
|
+
}
|
|
5330
5528
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
5331
5529
|
if (msg.op === "show") {
|
|
5332
5530
|
const row = bus.queueShow(msg.id);
|
|
@@ -5338,7 +5536,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5338
5536
|
bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
|
|
5339
5537
|
writeStatus();
|
|
5340
5538
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
5341
|
-
} catch { return void reply({ ok: false, error:
|
|
5539
|
+
} catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
|
|
5342
5540
|
}
|
|
5343
5541
|
case "delivery_holds": {
|
|
5344
5542
|
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
@@ -5364,9 +5562,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5364
5562
|
const envs = bus.pull(peer.id);
|
|
5365
5563
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
5366
5564
|
peer.lastInboxAt = Date.now();
|
|
5565
|
+
let polling: ReturnType<InboxPolling["read"]>;
|
|
5566
|
+
try {
|
|
5567
|
+
const assigned = peer.pullOnly && board.statusTasks().some(task => !isClosedTaskState(task.state) && task.state !== "backlog" && (task.owner === peer.id || task.reviewer === peer.id));
|
|
5568
|
+
polling = peer.pullOnly ? inboxPolling.read(peer.id, envs.length === 0, assigned, peer.lastInboxAt) : undefined;
|
|
5569
|
+
if (polling?.notice) notify(`${peer.id} is polling an empty inbox with no task (${polling.count} empty reads in ${polling.minutes} min)`);
|
|
5570
|
+
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
5367
5571
|
writeStatus();
|
|
5368
5572
|
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
5369
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
|
|
5573
|
+
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
5370
5574
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
5371
5575
|
}
|
|
5372
5576
|
case "delivery_complete": {
|
|
@@ -5700,16 +5904,43 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5700
5904
|
return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
|
|
5701
5905
|
}
|
|
5702
5906
|
case "sdlc": {
|
|
5907
|
+
if (msg.op === "release-guard" || msg.op === "release-guard-hook") {
|
|
5908
|
+
if (c.role !== "console" && c.role !== "tools" && c.role !== "peer" || c.peer && (!!turns.get(c.peer)?.private || contextPii(c.peer))) return void reply({ ok: false, error: "release guard unavailable for this caller" });
|
|
5909
|
+
if (msg.op === "release-guard") {
|
|
5910
|
+
const parsed = parseReleaseGuardArgv(msg.argv);
|
|
5911
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5912
|
+
void releaseAuthorizations.guardRequest(parsed.request).then(reply, () => reply({ ok: false, error: "release guard evidence unavailable" }));
|
|
5913
|
+
} else {
|
|
5914
|
+
const parsed = parseReleasePushUpdates(msg.remoteName, msg.remoteUrl, msg.stdin);
|
|
5915
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5916
|
+
reply(releaseAuthorizations.guardPush(parsed.remoteName, parsed.remoteUrl, parsed.updates));
|
|
5917
|
+
}
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5703
5920
|
if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
|
|
5921
|
+
if (msg.op === "authorize-release") {
|
|
5922
|
+
void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
|
|
5923
|
+
authorization => reply({ ok: true, authorization }),
|
|
5924
|
+
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
5925
|
+
);
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5704
5928
|
if (msg.op === "queue" || msg.op === "release") {
|
|
5705
5929
|
if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
|
|
5706
5930
|
const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
|
|
5707
|
-
void read.then(report => reply({ ok: true, report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
|
|
5931
|
+
void read.then(report => reply({ ok: true, report: msg.op === "release" ? releaseAuthorizations.report(report as Awaited<ReturnType<typeof tasks.releaseReadiness>>) : report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
|
|
5708
5932
|
return;
|
|
5709
5933
|
}
|
|
5710
5934
|
if (msg.op === "export") {
|
|
5711
5935
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5712
5936
|
}
|
|
5937
|
+
if (msg.op === "accept-departure") {
|
|
5938
|
+
void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
|
|
5939
|
+
() => reply({ ok: true }),
|
|
5940
|
+
() => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
|
|
5941
|
+
);
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5713
5944
|
if (msg.op === "accept") {
|
|
5714
5945
|
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5715
5946
|
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
|
@@ -5732,7 +5963,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5732
5963
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|
|
5733
5964
|
void (async () => {
|
|
5734
5965
|
const rows = [];
|
|
5735
|
-
for (const task of selected.slice(0, 100))
|
|
5966
|
+
for (const task of selected.slice(0, 100)) {
|
|
5967
|
+
const status = await tasks.lifecycleStatus(task.id, msg.id !== undefined);
|
|
5968
|
+
const current = board.get(task.id) ?? task;
|
|
5969
|
+
rows.push({ id: task.id, title: tasks.publicTitle(current), ...(tasks.isPii(current) ? {} : { receipt: tasks.publicView(current).sdlc }), ...status });
|
|
5970
|
+
}
|
|
5736
5971
|
reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
|
|
5737
5972
|
})().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
|
|
5738
5973
|
return;
|
|
@@ -5908,6 +6143,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5908
6143
|
budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
|
|
5909
6144
|
}
|
|
5910
6145
|
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6146
|
+
case "usage": {
|
|
6147
|
+
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6148
|
+
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
6149
|
+
if (c.role !== "console" && !planner) return void reply({ t: "usage", ok: false, error: "usage is read by the person and a configured planner" });
|
|
6150
|
+
if (planner && c.peer && turns.get(c.peer)?.private) return void reply({ t: "usage", ok: false, error: "usage is unavailable during a private turn" });
|
|
6151
|
+
const op = msg.op === undefined ? "query" : msg.op;
|
|
6152
|
+
const num = (v: unknown): number | undefined | null => v === undefined ? undefined : typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
6153
|
+
const since = num(msg.since), until = num(msg.until);
|
|
6154
|
+
if (since === null || until === null) return void reply({ t: "usage", ok: false, error: "since and until are epoch milliseconds" });
|
|
6155
|
+
if (msg.task !== undefined && !(Number.isSafeInteger(msg.task) && (msg.task as number) > 0)) return void reply({ t: "usage", ok: false, error: "task is a positive task id" });
|
|
6156
|
+
try {
|
|
6157
|
+
// Fold what the log has now, within a time budget: a hub that has just started is still behind a large log, and an answer built from the
|
|
6158
|
+
// part folded so far would read as "no records" for a peer that did use tokens. `behind` says so when the budget ran out (#683 U3).
|
|
6159
|
+
// ponytail: catchUp runs synchronously on the daemon loop, so a request on a log far behind stalls it for up to this budget (about 250 ms
|
|
6160
|
+
// folds the real 47 MB log). Ceiling: a log several times larger answers `behind` and finishes over the 10 s polls; move the fold to a worker if a stall is felt.
|
|
6161
|
+
const behind = !usageRollup.catchUp(250);
|
|
6162
|
+
const reply_ = (body: Record<string, unknown>) => reply({ ...body, ...(behind ? { behind: true } : {}) });
|
|
6163
|
+
if (op === "limits") {
|
|
6164
|
+
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ t: "usage", ok: false, error: "peer must be a peer id" });
|
|
6165
|
+
if (msg.limit !== undefined && !(Number.isInteger(msg.limit) && (msg.limit as number) >= 1 && (msg.limit as number) <= LIMITS_MAX)) return void reply({ t: "usage", ok: false, error: `limit is an integer from 1 to ${LIMITS_MAX}` });
|
|
6166
|
+
const status = budget.status();
|
|
6167
|
+
// The pauses the hub holds now: an event-open pause it no longer holds (an `ahub reset` cleared it) is reported as ended, not open.
|
|
6168
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
6169
|
+
return void reply_({ t: "usage", ok: true, op, limits: usageRollup.limits(msg.peer, { ...(msg.limit !== undefined ? { limit: msg.limit as number } : {}), live }), current: publicPeerBudget(status, text => tasks.nameable(text)) });
|
|
6170
|
+
}
|
|
6171
|
+
if (op === "total") return void reply_({ t: "usage", ok: true, op, total: usageRollup.total() });
|
|
6172
|
+
if (op !== "query") return void reply({ t: "usage", ok: false, error: "op is query, limits or total" });
|
|
6173
|
+
const by = (msg.by ?? "peer") as UsageGroup;
|
|
6174
|
+
if (!USAGE_GROUPS.includes(by)) return void reply({ t: "usage", ok: false, error: `by is one of ${USAGE_GROUPS.join(", ")}` });
|
|
6175
|
+
// issue, pr and release are the board's current links of each task (ids only, never a title).
|
|
6176
|
+
const board_ = new Map(board.list().map(t => [t.id, t]));
|
|
6177
|
+
const links = (id: number) => { const t = board_.get(id); return t ? { ...(t.sdlc?.issue ? { issue: t.sdlc.issue } : {}), ...(t.sdlc?.pr ? { pr: t.sdlc.pr } : {}), ...(t.release ? { release: t.release } : {}) } : undefined; };
|
|
6178
|
+
return void reply_({ t: "usage", ok: true, op, usage: usageRollup.query({ by, ...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}), ...(msg.task !== undefined ? { task: msg.task as number } : {}) }, links) });
|
|
6179
|
+
} catch (error) { return void reply({ t: "usage", ok: false, error: `usage unavailable: ${(error as Error).message.slice(0, 120)}` }); }
|
|
6180
|
+
}
|
|
5911
6181
|
case "permit":
|
|
5912
6182
|
if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
|
|
5913
6183
|
return void reply({ t: "permit", ok: permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined, msg.surface === "console" ? "console" : "terminal") === true });
|
|
@@ -5936,6 +6206,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5936
6206
|
stopping = true;
|
|
5937
6207
|
terminalLaunches.clear();
|
|
5938
6208
|
// Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
|
|
6209
|
+
const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
|
|
5939
6210
|
const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
|
|
5940
6211
|
runnerRefreshAbort.abort();
|
|
5941
6212
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
@@ -5951,7 +6222,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5951
6222
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
5952
6223
|
for (const p of permissions.values()) p.done(undefined);
|
|
5953
6224
|
try {
|
|
5954
|
-
await terminalReadsStopped;
|
|
6225
|
+
await Promise.all([terminalReadsStopped, codeFactsStopped]);
|
|
5955
6226
|
await terminalLaunches.settled();
|
|
5956
6227
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
5957
6228
|
// Drain those starts before taking the final owned-process snapshot.
|
|
@@ -6054,6 +6325,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6054
6325
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6055
6326
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6056
6327
|
} finally {
|
|
6057
|
-
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
6328
|
+
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6058
6329
|
}
|
|
6059
6330
|
}
|