@staix/agent-hub 0.14.0 → 0.16.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 +42 -0
- package/README.md +23 -9
- package/docs/agent-notes/README.md +8 -0
- package/docs/agent-notes/adapters.md +21 -4
- package/docs/agent-notes/budget.md +6 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +20 -9
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +21 -4
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +19 -5
- package/docs/events.md +21 -7
- package/docs/memory.md +78 -10
- package/docs/operations.md +197 -39
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +16 -6
- package/docs/sdlc.md +17 -0
- package/docs/security.md +110 -21
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +40 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3109 -2046
- package/src/adapters/acp.ts +46 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +6 -2
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -5
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +8 -4
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +30 -53
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +48 -3
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +30 -2
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +10 -7
- package/src/cli/identity.ts +3 -1
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +91 -19
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +40 -4
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +90 -0
- package/src/hub/action-policy.ts +40 -17
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +110 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +44 -7
- package/src/hub/bus.ts +6 -6
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +12 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +614 -104
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +5 -3
- package/src/hub/hub-tools.ts +22 -6
- package/src/hub/instances.ts +7 -4
- package/src/hub/kimi-usage.ts +156 -0
- package/src/hub/lifecycle.ts +10 -5
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-code-head.ts +44 -0
- package/src/hub/memory-code-overlay.ts +119 -0
- package/src/hub/memory-plan-guard.ts +10 -1
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +18 -3
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +551 -75
- package/src/hub/token-window.ts +46 -0
- package/src/hub/usage-rollup.ts +16 -6
- package/src/hub/window-kind.ts +13 -0
- package/src/hub/workflow.ts +16 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +215 -19
package/src/hub/daemon.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { deriveMemoryCodeOverlay, unavailableMemoryCodeOverlay } from "./memory-code-overlay.ts";
|
|
2
|
+
import { readMemoryCodeHead, type MemoryCodeHead } from "./memory-code-head.ts";
|
|
3
|
+
import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import { normalizeUsage } from "../omniroute/usage.ts";
|
|
6
|
+
import { permissionPayload } from "./permission-payload.ts";
|
|
7
|
+
import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
|
|
1
8
|
import { InboxPolling } from "./inbox-polling.ts";
|
|
2
9
|
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
10
|
+
import { actionGate, claudeActionClasses } from "./action-enforce.ts";
|
|
3
11
|
import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
|
|
4
12
|
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
|
|
5
13
|
import type { DocEvidence } from "./sdlc-release.ts";
|
|
@@ -16,7 +24,7 @@ import { renderHeldHint } from "./delivery-hints.ts";
|
|
|
16
24
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
17
25
|
import { MemoryObservations } from "./memory-observations.ts";
|
|
18
26
|
import { createStalenessProbe } from "./memory-staleness.ts";
|
|
19
|
-
import { matchingPlanFails, planGuardNodes } from "./memory-plan-guard.ts";
|
|
27
|
+
import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
|
|
20
28
|
import { planningWipLimits } from "./planning.ts";
|
|
21
29
|
import { projectedDiff } from "./projected-diff.ts";
|
|
22
30
|
import { listenWithoutPortCollision } from "./listen.ts";
|
|
@@ -58,11 +66,17 @@ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrust
|
|
|
58
66
|
import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
|
|
59
67
|
import { resolveSettledRoles } from "./settled-roles.ts";
|
|
60
68
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
61
|
-
import {
|
|
69
|
+
import { createConfigEditor } from "./config-edit-host.ts";
|
|
70
|
+
import { readEditable } from "./config-edit.ts";
|
|
71
|
+
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
72
|
+
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
73
|
+
import { TokenWindow } from "./token-window.ts";
|
|
74
|
+
import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
|
|
75
|
+
import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
|
|
62
76
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
63
77
|
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
64
78
|
import type { ServerWebSocket } from "bun";
|
|
65
|
-
import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
79
|
+
import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
|
|
66
80
|
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
67
81
|
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
68
82
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
@@ -79,7 +93,7 @@ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableErro
|
|
|
79
93
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
80
94
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
81
95
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
82
|
-
import { Capture
|
|
96
|
+
import { Capture } from "../memory/capture.ts";
|
|
83
97
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
84
98
|
import { Briefs } from "../memory/brief.ts";
|
|
85
99
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
@@ -93,7 +107,7 @@ import { fileURLToPath } from "node:url";
|
|
|
93
107
|
import { realPath, hubHome } from "./project.ts";
|
|
94
108
|
import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
|
|
95
109
|
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
96
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
110
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
97
111
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
98
112
|
import { SupervisionFeed } from "./supervision.ts";
|
|
99
113
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -111,7 +125,7 @@ import { codeGroup } from "./task-code-group.ts";
|
|
|
111
125
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
112
126
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
113
127
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
114
|
-
import { MemoryClient
|
|
128
|
+
import { MemoryClient } from "../memory/client.ts";
|
|
115
129
|
import { VERSION } from "../version.ts";
|
|
116
130
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
117
131
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
@@ -135,9 +149,9 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
|
|
|
135
149
|
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
136
150
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
137
151
|
import { detectTooling } from "./tooling.ts";
|
|
138
|
-
import { looksSecret, DEFAULT_LEASE_MS,
|
|
152
|
+
import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
139
153
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
140
|
-
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates,
|
|
154
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
141
155
|
import { syncMemory } from "@staix/agent-mem";
|
|
142
156
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
143
157
|
import { nativeRecall } from "../memory/recall.ts";
|
|
@@ -168,7 +182,7 @@ export interface HubConfig {
|
|
|
168
182
|
roster: PeerRoster;
|
|
169
183
|
roles: Record<string, string[]>;
|
|
170
184
|
/** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
|
|
171
|
-
planner?: string;
|
|
185
|
+
planner?: string | string[];
|
|
172
186
|
profile?: ProfileName;
|
|
173
187
|
profile_overrides?: Record<string, unknown>;
|
|
174
188
|
pii?: { paths: string[] };
|
|
@@ -277,11 +291,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
|
|
|
277
291
|
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"];
|
|
278
292
|
|
|
279
293
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
280
|
-
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
294
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
281
295
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
282
296
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
283
297
|
const notices: string[] = [];
|
|
284
|
-
let planner: string | undefined;
|
|
298
|
+
let planner: string | string[] | undefined;
|
|
285
299
|
for (const name of CONFIG_FILES) {
|
|
286
300
|
let file: Record<string, unknown>;
|
|
287
301
|
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
@@ -299,8 +313,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
299
313
|
}
|
|
300
314
|
}
|
|
301
315
|
if (file.planner !== undefined) {
|
|
302
|
-
if (
|
|
303
|
-
|
|
316
|
+
if (file.planner === null) throw new Error("planner takes a peer id or a nonempty list of up to eight ids");
|
|
317
|
+
const def = instanceSettingDef("planner", scope.instances, scope.catalog);
|
|
318
|
+
if (typeof def === "string") throw new Error(def);
|
|
319
|
+
const normalized = checkSettingValue(def, file.planner) as string | string[];
|
|
320
|
+
planner = Array.isArray(file.planner) ? [...file.planner] as string[] : normalized;
|
|
304
321
|
}
|
|
305
322
|
if (file.conductor !== undefined) {
|
|
306
323
|
if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
|
|
@@ -409,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
409
426
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
410
427
|
}
|
|
411
428
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
412
|
-
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
429
|
+
const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
|
|
413
430
|
retired.push(...conductorPolicy.notices ?? []);
|
|
414
431
|
const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
415
432
|
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
|
|
@@ -492,6 +509,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
492
509
|
}
|
|
493
510
|
|
|
494
511
|
export interface DaemonOptions {
|
|
512
|
+
/** Host-only test/embedding port; never read from project configuration or a tool. */
|
|
513
|
+
memoryClient?: MemoryClient;
|
|
495
514
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
496
515
|
snapshot?: typeof snapshot;
|
|
497
516
|
snapshotBurstMs?: number;
|
|
@@ -500,6 +519,8 @@ export interface DaemonOptions {
|
|
|
500
519
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
501
520
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
502
521
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
522
|
+
/** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
|
|
523
|
+
kimiHome?: string;
|
|
503
524
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
504
525
|
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
505
526
|
projectId?: string;
|
|
@@ -544,6 +565,7 @@ interface Client {
|
|
|
544
565
|
role?: "peer" | "console" | "tools";
|
|
545
566
|
peer?: PeerId;
|
|
546
567
|
launchId?: string;
|
|
568
|
+
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
547
569
|
tail?: () => void;
|
|
548
570
|
watch?: () => void;
|
|
549
571
|
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
@@ -603,7 +625,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
603
625
|
const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
|
|
604
626
|
...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
|
|
605
627
|
const executionRoots = new Map<string, string>();
|
|
606
|
-
const
|
|
628
|
+
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
629
|
+
const implementerTranscripts = new Map<string, string>();
|
|
630
|
+
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
631
|
+
const verifiedTerminalRootsThisTurn = new Set<object>();
|
|
607
632
|
const reviewerUnavailable = new Map<string, string>();
|
|
608
633
|
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
609
634
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
@@ -679,6 +704,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
679
704
|
let configRevision = 0;
|
|
680
705
|
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
681
706
|
let pendingRestart: { key: string; reason: string }[] = [];
|
|
707
|
+
// #689: structural config edits. Assigned with the settings section; status and the settings view read it before then, so it is held here.
|
|
708
|
+
let configEditor: ReturnType<typeof createConfigEditor> | undefined;
|
|
709
|
+
const structuralScopes = new Set<string>();
|
|
710
|
+
let sdlcFileReads = 0;
|
|
711
|
+
let sdlcFileCache: { stamp: string; value: { mode: string; requiredChecks: readonly string[] } | string } | undefined;
|
|
712
|
+
/** What waits for a restart: the reload's own keys, then a changed sdlc.toml (read once at start). */
|
|
713
|
+
const allPendingRestart = (): { key: string; reason: string }[] => [...pendingRestart, ...(configEditor?.pendingRestart() ?? [])];
|
|
682
714
|
let retainedConfig: { key: string; reason: string }[] = [];
|
|
683
715
|
function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
|
|
684
716
|
const rows = new Map<string, string>();
|
|
@@ -713,8 +745,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
713
745
|
let policyRoles = structuredClone(config.roles);
|
|
714
746
|
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
715
747
|
let plannerLogged = "";
|
|
748
|
+
const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
|
|
716
749
|
const settlePlanner = () => {
|
|
717
|
-
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
750
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
|
|
718
751
|
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
719
752
|
Object.assign(config.roles, roles);
|
|
720
753
|
plannerSource = source;
|
|
@@ -722,7 +755,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
722
755
|
if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
|
|
723
756
|
};
|
|
724
757
|
const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
|
|
725
|
-
if (config.planner !== undefined && !(PLANNER_PEERS as readonly string[]).includes(config.planner)) log(`planner: ${JSON.stringify(config.planner)} is not one of ${PLANNER_PEERS.join(", ")}; ignored`);
|
|
726
758
|
settlePlanner();
|
|
727
759
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
728
760
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
@@ -840,6 +872,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
840
872
|
sock.data.watch?.();
|
|
841
873
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
842
874
|
if (isWsPeer(peer)) {
|
|
875
|
+
if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
|
|
843
876
|
peer.detach(sock);
|
|
844
877
|
if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
845
878
|
}
|
|
@@ -1030,7 +1063,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1030
1063
|
}, delayMs);
|
|
1031
1064
|
recoveryLeaseTimer.unref?.();
|
|
1032
1065
|
};
|
|
1033
|
-
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
1034
1066
|
const chain = projectChain(opts.cwd);
|
|
1035
1067
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
1036
1068
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
@@ -1074,18 +1106,96 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1074
1106
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
1075
1107
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
1076
1108
|
*/
|
|
1077
|
-
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
1109
|
+
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs.filter(v => !looksSecret(`${v.title ?? ""}\n${v.text}`) && !v.title?.startsWith(ASK_NOTE_TITLE)), activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
1078
1110
|
let candidates: CandidateStore | undefined;
|
|
1079
1111
|
if (memoryStore) {
|
|
1080
1112
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
1081
1113
|
catch { log("memory candidate sidecar unavailable"); }
|
|
1082
1114
|
}
|
|
1115
|
+
let captureBinding = memoryBinding;
|
|
1116
|
+
if (config.memory.enabled && !captureBinding && !memoryProblem) {
|
|
1117
|
+
try { captureBinding = new MemoryProfileBinding({ project: memoryProject, projectKey: memoryProjectRaw }); }
|
|
1118
|
+
catch { memoryUnavailable("memory profile unavailable; capture disabled"); }
|
|
1119
|
+
}
|
|
1120
|
+
const memory = opts.memoryClient ?? new MemoryClient({
|
|
1121
|
+
project: memoryProject, store: memoryStore, candidates,
|
|
1122
|
+
check: () => { if (memoryProblem || !captureBinding) throw new Error("memory unavailable"); captureBinding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) throw new Error("memory project changed"); },
|
|
1123
|
+
screen: rows => screenMemory(rows),
|
|
1124
|
+
publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
|
|
1125
|
+
capture: () => ({ memoryDir: captureBinding!.home }), log,
|
|
1126
|
+
});
|
|
1083
1127
|
const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
|
|
1084
1128
|
const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
|
|
1085
1129
|
const memoryLine = (v: MemoryView) => `mem:${v.id.slice(0, 8)} ${v.kind} ${v.tier}: ${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 160)}`;
|
|
1086
1130
|
/** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
|
|
1087
1131
|
const taskNodes = (t: { id?: number; created?: number; title?: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }) =>
|
|
1088
|
-
|
|
1132
|
+
taskMemoryQueryNodes({ ...t, title: t.title ?? "" }, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny });
|
|
1133
|
+
|
|
1134
|
+
const memoryCodeCache = new Map<string, { code: import("./memory-code-overlay.ts").MemoryCodeOverlay; proof: MemoryCodeHead; expires: number }>();
|
|
1135
|
+
const memoryCodeProofs = new Map<string, MemoryCodeHead>();
|
|
1136
|
+
const memoryCodeSeeds = new WeakMap<object, Set<string>>();
|
|
1137
|
+
let memoryCodeNextStart = 0;
|
|
1138
|
+
async function memoryCode(starts: readonly string[], valid: () => boolean = () => true, cacheOnly = false) {
|
|
1139
|
+
if (!memoryStore || !starts.length || !valid()) return unavailableMemoryCodeOverlay();
|
|
1140
|
+
const key = JSON.stringify([memoryRepo, starts]), now = performance.now(), cached = memoryCodeCache.get(key);
|
|
1141
|
+
if (cached && cached.expires > now && cached.proof.current() === cached.code.head) return cached.code;
|
|
1142
|
+
if (cacheOnly || now < memoryCodeNextStart) return unavailableMemoryCodeOverlay();
|
|
1143
|
+
memoryCodeNextStart = now + 5000;
|
|
1144
|
+
const proof = readMemoryCodeHead(opts.cwd); if (!proof) return unavailableMemoryCodeOverlay();
|
|
1145
|
+
const head = proof.head; memoryCodeProofs.set(head, proof);
|
|
1146
|
+
if (memoryCodeProofs.size > 32) memoryCodeProofs.delete(memoryCodeProofs.keys().next().value!);
|
|
1147
|
+
const code = await deriveMemoryCodeOverlay(starts, {
|
|
1148
|
+
context: () => ({ cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny }),
|
|
1149
|
+
head: () => head,
|
|
1150
|
+
valid: () => { checkMemory(); return !stopping && valid() && proof.current() === head; },
|
|
1151
|
+
publicMetadataText: text => tasks.publicMetadataText(text) && boundedSafe(text),
|
|
1152
|
+
neighbours: (root, head, selector, depth) => codeFacts.neighbours(root, head, selector, depth, { priority: "optional" }),
|
|
1153
|
+
related: (nodes, limit) => screenMemory(memoryStore!.related(memoryProject, [...nodes], limit)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })),
|
|
1154
|
+
});
|
|
1155
|
+
memoryCodeSeeds.set(code, new Set(starts.map(raw => canonicalMemorySymbolNode(raw, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText })).filter((id): id is string => !!id)));
|
|
1156
|
+
memoryCodeCache.set(key, { code, proof, expires: performance.now() + (code.state === "known" ? 30_000 : 5000) });
|
|
1157
|
+
if (memoryCodeCache.size > 32) memoryCodeCache.delete(memoryCodeCache.keys().next().value!);
|
|
1158
|
+
return code;
|
|
1159
|
+
}
|
|
1160
|
+
function currentMemoryCode(code: import("./memory-code-overlay.ts").MemoryCodeOverlay) {
|
|
1161
|
+
try {
|
|
1162
|
+
checkMemory();
|
|
1163
|
+
if (!code.head || memoryCodeProofs.get(code.head)?.current() !== code.head || !tasks.publicMetadataText(JSON.stringify({ ...code, notes: [] })) || !boundedSafe(JSON.stringify({ ...code, notes: [] }))) return unavailableMemoryCodeOverlay();
|
|
1164
|
+
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: (text: string) => tasks.publicMetadataText(text) && boundedSafe(text) };
|
|
1165
|
+
if (code.nodes.some(node => canonicalMemorySymbolNode(node.id, context) !== node.id)) return unavailableMemoryCodeOverlay();
|
|
1166
|
+
for (const note of code.notes) {
|
|
1167
|
+
const row = memoryStore?.get(note.id.slice(4), memoryProject);
|
|
1168
|
+
if (!row || `mem:${row.id}` !== note.id || !screenMemory([row]).length || !boundedSafe(JSON.stringify(row))) return unavailableMemoryCodeOverlay();
|
|
1169
|
+
}
|
|
1170
|
+
const seeds = memoryCodeSeeds.get(code) ?? new Set<string>();
|
|
1171
|
+
const neighbours = code.nodes.map(node => node.id).filter(id => !seeds.has(id));
|
|
1172
|
+
const rows = memoryStore && neighbours.length ? screenMemory(memoryStore.related(memoryProject, neighbours, 128)) : [];
|
|
1173
|
+
const notes = rows.filter(row => row.about.some(id => neighbours.includes(id)) && boundedSafe(JSON.stringify(row)))
|
|
1174
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || b.at.localeCompare(a.at)).slice(0, 16)
|
|
1175
|
+
.map(row => ({ id: `mem:${row.id}`, line: memoryLine(row), kind: row.kind, about: row.about, tier: row.tier, peer: row.peer, machine: row.machine, at: row.at }));
|
|
1176
|
+
return { ...code, notes };
|
|
1177
|
+
} catch { return unavailableMemoryCodeOverlay(); }
|
|
1178
|
+
}
|
|
1179
|
+
async function taskMemoryResult(task: import("../memory/brief.ts").BriefTask, cacheOnly = false) {
|
|
1180
|
+
const maximum = config.memory.brief_items;
|
|
1181
|
+
const current = () => task.id === undefined ? undefined : board.get(task.id), initial = current();
|
|
1182
|
+
const valid = () => { const value = current(); return !!initial && !!value && value.created === task.created && value.updated === initial.updated && value.history.length === initial.history.length && value.owner === initial.owner && tasks.memoryCodeEligible(value) && !turns.get(value.owner ?? "")?.private && !contextPii(value.owner ?? ""); };
|
|
1183
|
+
if (!memoryStore || initial && tasks.isPii(initial)) return { items: [], state: "unknown" as const };
|
|
1184
|
+
const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
|
|
1185
|
+
let code = unavailableMemoryCodeOverlay();
|
|
1186
|
+
try { if (valid() && starts.length) { const prepared = await memoryCode(starts, valid, cacheOnly); if (valid()) code = currentMemoryCode(prepared); } }
|
|
1187
|
+
catch { /* Optional code failure must not discard existing direct memory advice. */ }
|
|
1188
|
+
checkMemory();
|
|
1189
|
+
const latest = current(); if (latest && tasks.isPii(latest)) return { items: [], state: "unknown" as const };
|
|
1190
|
+
// Optional code evidence never suppresses the existing direct path/title/symbol memory brief.
|
|
1191
|
+
const direct = screenMemory(memoryStore.related(memoryProject, taskNodes(task), 128)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
|
|
1192
|
+
const items = [...new Map([...direct, ...code.notes].map(row => [row.id, row])).values()]
|
|
1193
|
+
.filter(row => [row.line, ...(row.about ?? [])].every(tasks.publicMetadataText))
|
|
1194
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || Number(b.kind === "decision") - Number(a.kind === "decision"))
|
|
1195
|
+
.slice(0, maximum);
|
|
1196
|
+
return { items, state: starts.length ? code.state : "known" as const, ...(code.head ? { head: code.head } : {}) };
|
|
1197
|
+
}
|
|
1198
|
+
async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
|
|
1089
1199
|
|
|
1090
1200
|
const leaseNoticed = new Set<string>();
|
|
1091
1201
|
const goalLine = (g: GoalView) => `goal:${g.id.slice(0, 8)} ${g.state} ${g.title}${g.lease.holder ? ` (held by ${g.lease.holder === memoryStore?.machine.id ? "this machine" : `machine ${g.lease.holder.slice(0, 8)}`} until ${g.lease.until}${g.lease.conflict.length ? `; conflicting claim from ${g.lease.conflict.map((m) => m.slice(0, 8)).join(", ")}` : ""})` : ""}`;
|
|
@@ -1157,7 +1267,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1157
1267
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1158
1268
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1159
1269
|
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
|
|
1160
|
-
// The hub's own long-term decisions, contracts and fails lead;
|
|
1270
|
+
// The hub's own long-term decisions, contracts and fails lead; amem's curated short-term notes follow, each with half the budget.
|
|
1161
1271
|
let own: string | undefined;
|
|
1162
1272
|
try {
|
|
1163
1273
|
checkMemory();
|
|
@@ -1224,6 +1334,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1224
1334
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1225
1335
|
const codeFacts = new CodeFacts();
|
|
1226
1336
|
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1337
|
+
const memorySymbols = new Map<string, MemorySymbolNodes>();
|
|
1227
1338
|
startupCleanup.push(() => board.close());
|
|
1228
1339
|
startupCleanup.push(() => codeFacts.close());
|
|
1229
1340
|
const supervision = new SupervisionFeed({
|
|
@@ -1405,12 +1516,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1405
1516
|
reviewOnly: reviewOnlyPeer,
|
|
1406
1517
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1407
1518
|
reviewAuthority: runningReviewAuthority,
|
|
1519
|
+
requestAuthority: by => {
|
|
1520
|
+
const scope = childToolAuthority.getStore();
|
|
1521
|
+
return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
|
|
1522
|
+
},
|
|
1408
1523
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1409
1524
|
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1410
1525
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1411
1526
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1412
1527
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
1413
|
-
const bound =
|
|
1528
|
+
const bound = currentExecutionOwner(id);
|
|
1414
1529
|
return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
|
|
1415
1530
|
})),
|
|
1416
1531
|
recordPlanning: change => event({ type: "planning", ...change }),
|
|
@@ -1419,6 +1534,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1419
1534
|
profile: profileName(config.profile),
|
|
1420
1535
|
profileData: resolvedProfile,
|
|
1421
1536
|
piiPaths: config.pii?.paths,
|
|
1537
|
+
deny: config.local.deny,
|
|
1538
|
+
// #509a: deliverables are hashed under the owner's verified execution root (a declared worktree instance's worktree), `undefined` when unproven.
|
|
1539
|
+
executionRoot: peer => ownerWriteRoot(peer),
|
|
1422
1540
|
catalog,
|
|
1423
1541
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1424
1542
|
projectedReview: task => reviewProjection(task),
|
|
@@ -1430,8 +1548,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1430
1548
|
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1431
1549
|
if ("unavailable" in brief) return;
|
|
1432
1550
|
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1433
|
-
|
|
1434
|
-
if (
|
|
1551
|
+
// prepareOnly and completion share one exact-tree pass, including an unknown result.
|
|
1552
|
+
if (memoryStore && memorySymbols.has(key)) return;
|
|
1553
|
+
const observed = await codeFacts.changedSymbols(root, brief.mergeBase, brief.head);
|
|
1554
|
+
reviewSymbols.set(key, observed);
|
|
1555
|
+
const candidates = tasks.memorySymbolCandidates(task);
|
|
1556
|
+
if (memoryStore && candidates && !tasks.isPii(board.get(task.id) ?? task)) {
|
|
1557
|
+
const nodes = await resolveMemorySymbolNodes(candidates,
|
|
1558
|
+
{ cwd: root, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText },
|
|
1559
|
+
name => { const current = board.get(task.id); return !current || current.owner !== task.owner || tasks.isPii(current) ? Promise.resolve({ state: "unknown" }) : codeFacts.exists(root, brief.head, name); });
|
|
1560
|
+
memorySymbols.set(key, nodes);
|
|
1561
|
+
}
|
|
1562
|
+
if (reviewSymbols.size > 64) { const oldest = reviewSymbols.keys().next().value!; reviewSymbols.delete(oldest); memorySymbols.delete(oldest); }
|
|
1563
|
+
},
|
|
1564
|
+
memorySymbolNodes: task => {
|
|
1565
|
+
if (tasks.isPii(task) || !task.codeSymbols || task.codeSymbols.head !== task.refs.commit) return undefined;
|
|
1566
|
+
const root = executionRoot(task.owner ?? "");
|
|
1567
|
+
return memorySymbols.get(JSON.stringify([root, task.codeSymbols.head, task.codeSymbols.mergeBase]));
|
|
1435
1568
|
},
|
|
1436
1569
|
reviewBrief: task => {
|
|
1437
1570
|
const root = executionRoot(task.owner ?? "");
|
|
@@ -1449,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1449
1582
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1450
1583
|
memoryCheck: checkMemory,
|
|
1451
1584
|
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1452
|
-
memoryStore ?
|
|
1453
|
-
(peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
|
|
1585
|
+
memoryStore ? taskMemoryItems : undefined,
|
|
1586
|
+
(peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
|
|
1454
1587
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
|
|
1455
1588
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
1456
1589
|
checkMemory();
|
|
@@ -1554,7 +1687,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1554
1687
|
const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
|
|
1555
1688
|
const worktreeFacts = new Map<string, Facts>();
|
|
1556
1689
|
const worktreeProjections = new Map<string, TextProjections>();
|
|
1557
|
-
const executionRoot = (peer: string) =>
|
|
1690
|
+
const executionRoot = (peer: string) => {
|
|
1691
|
+
if (executionOwners.get(peer)?.socket && !currentExecutionOwner(peer)) return opts.cwd;
|
|
1692
|
+
return executionRoots.get(peer) ?? opts.cwd;
|
|
1693
|
+
};
|
|
1558
1694
|
const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
|
|
1559
1695
|
const projectionsFor = (peer: string): TextProjections => {
|
|
1560
1696
|
const root = executionRoot(peer);
|
|
@@ -1856,7 +1992,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1856
1992
|
}
|
|
1857
1993
|
for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
|
|
1858
1994
|
const unreadMoves = [...moves.values()];
|
|
1859
|
-
if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume)
|
|
1995
|
+
if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiUsage.reset(); // a new window: the old counts would pause it again at once
|
|
1860
1996
|
const moved = unreadMoves.length ? `While you were paused these moved: ${unreadMoves.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : earlier.length ? "Moves from earlier pauses are preserved in the unread notices below." : "Nothing was moved while you were paused.";
|
|
1861
1997
|
bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved}${earlier.length ? ` Earlier unread resume notices: ${earlier.join("\n")}` : ""} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important", refs: { budgetResume: record.peer, budgetResumeMoves: unreadMoves, ...(earlier.length ? { budgetResumeEarlier: earlier } : {}) } }));
|
|
1862
1998
|
// Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
|
|
@@ -1868,8 +2004,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1868
2004
|
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1869
2005
|
// 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
|
|
1870
2006
|
// 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).
|
|
1871
|
-
|
|
1872
|
-
|
|
2007
|
+
const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
|
|
2008
|
+
budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
|
|
2009
|
+
// #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
|
|
2010
|
+
const kimiUsage = new KimiUsage({
|
|
2011
|
+
home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
|
|
2012
|
+
now: () => Date.now(),
|
|
2013
|
+
ceiling: () => config.budget.kimi_tokens_5h,
|
|
2014
|
+
session: peer => contextSession(peer),
|
|
2015
|
+
record: (peer, row, counted) => {
|
|
2016
|
+
addTokens(peer, counted);
|
|
2017
|
+
event({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
|
|
2018
|
+
cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
|
|
2019
|
+
...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
|
|
2020
|
+
},
|
|
2021
|
+
report: (peer, window) => budget.report(peer, [{ id: "tokens", kind: "count", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
|
|
2022
|
+
forget: peer => budget.forget(peer, "tokens", "count"),
|
|
2023
|
+
notice: line => notify(line),
|
|
2024
|
+
});
|
|
2025
|
+
kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
|
|
2026
|
+
/** Why a peer shows no number (no quota, nothing read yet): one line each, from the definition and the counters, never from a log's text. */
|
|
2027
|
+
const budgetPeerNotes = (): BudgetPeerNote[] => {
|
|
2028
|
+
const quota = budget.status();
|
|
2029
|
+
const peers = [...new Set([...bus.knownPeers().filter(peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline"), ...Object.keys(quota)])].filter(peer => peer !== USER && peer !== HUB);
|
|
2030
|
+
return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
|
|
2031
|
+
hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
|
|
2032
|
+
};
|
|
2033
|
+
/** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a usage_update. */
|
|
2034
|
+
const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
|
|
2035
|
+
const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
1873
2036
|
startupCleanup.push(() => budget.close());
|
|
1874
2037
|
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1875
2038
|
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
@@ -1968,13 +2131,70 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1968
2131
|
function privateSnapshotWindow(): boolean {
|
|
1969
2132
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
1970
2133
|
}
|
|
2134
|
+
function revokeTerminalExecution(peer: string): void {
|
|
2135
|
+
const bound = executionOwners.get(peer);
|
|
2136
|
+
if (bound) verifiedTerminalRootsThisTurn.delete(bound);
|
|
2137
|
+
executionOwners.delete(peer); executionRoots.delete(peer);
|
|
2138
|
+
worktreeFacts.delete(peer); worktreeProjections.delete(peer);
|
|
2139
|
+
}
|
|
2140
|
+
function currentExecutionOwner(peer: string) {
|
|
2141
|
+
const bound = executionOwners.get(peer);
|
|
2142
|
+
if (bound?.socket) {
|
|
2143
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2144
|
+
if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2145
|
+
revokeTerminalExecution(peer);
|
|
2146
|
+
return undefined;
|
|
2147
|
+
}
|
|
2148
|
+
// A new event-loop turn revalidates Git inventory and the attached branch before
|
|
2149
|
+
// an admission/effect. Reuse only within synchronous code, never across an await.
|
|
2150
|
+
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2151
|
+
try {
|
|
2152
|
+
if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2153
|
+
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2154
|
+
verifiedTerminalRootsThisTurn.add(bound);
|
|
2155
|
+
queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
2156
|
+
}
|
|
2157
|
+
// The hello claim is not attached until its asynchronous preface finishes.
|
|
2158
|
+
if (!bound.owner.owns(bound.socket)) return undefined;
|
|
2159
|
+
} else if (bound && definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
2160
|
+
try {
|
|
2161
|
+
if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
|
|
2162
|
+
if (!verifiedTerminalRootsThisTurn.has(bound)) {
|
|
2163
|
+
if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
|
|
2164
|
+
verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
|
|
2165
|
+
}
|
|
2166
|
+
} catch { revokeTerminalExecution(peer); return undefined; }
|
|
2167
|
+
}
|
|
2168
|
+
return bound;
|
|
2169
|
+
}
|
|
1971
2170
|
function ownerWriteRoot(peer: string): string | undefined {
|
|
1972
2171
|
if (declarations[peer]) {
|
|
1973
|
-
const bound =
|
|
2172
|
+
const bound = currentExecutionOwner(peer);
|
|
1974
2173
|
if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
|
|
1975
2174
|
}
|
|
1976
2175
|
return executionRoot(peer);
|
|
1977
2176
|
}
|
|
2177
|
+
function implementerChatTask(peer: string): number | undefined {
|
|
2178
|
+
const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
|
|
2179
|
+
return owned.length === 1 ? owned[0]!.id : undefined;
|
|
2180
|
+
}
|
|
2181
|
+
function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
|
|
2182
|
+
const bound = c.toolsOwner, peer = c.peer;
|
|
2183
|
+
if (!bound || !peer || c.role !== "tools" || stopping || recoveryActive() || !membershipAllowed(peer) || contextPii(peer) || definitionOf(peer)?.start.daemonPort !== "claude-implementer" || bus.peers.get(peer) !== bound.owner) return undefined;
|
|
2184
|
+
const launch = bound.owner.ownedTools?.(bound.launchId);
|
|
2185
|
+
if (!launch || launch.signal.aborted || launch.generation !== bound.generation || bound.cwd === opts.cwd) return undefined;
|
|
2186
|
+
const root = verifyRoot ? ownerWriteRoot(peer) : executionOwners.get(peer)?.owner === bound.owner ? executionRoots.get(peer) : undefined;
|
|
2187
|
+
if (root !== bound.cwd) return undefined;
|
|
2188
|
+
if (launch.task !== undefined) {
|
|
2189
|
+
const task = board.get(launch.task);
|
|
2190
|
+
if (!task || task.owner !== peer || task.state === "cancelled" || tasks.isPii(task)) return undefined;
|
|
2191
|
+
}
|
|
2192
|
+
return launch;
|
|
2193
|
+
}
|
|
2194
|
+
function captureImplementerTools(c: Client): () => boolean {
|
|
2195
|
+
const binding = c.toolsOwner, launch = currentImplementerTools(c);
|
|
2196
|
+
return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
|
|
2197
|
+
}
|
|
1978
2198
|
function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
|
|
1979
2199
|
// ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
|
|
1980
2200
|
// Bind each owner to its real execution worktree before narrowing this unknown scope.
|
|
@@ -2033,28 +2253,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2033
2253
|
const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
|
|
2034
2254
|
return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
|
|
2035
2255
|
}
|
|
2036
|
-
|
|
2037
|
-
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2256
|
+
/** Tokens a peer used, as increments. */
|
|
2038
2257
|
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
2039
|
-
|
|
2258
|
+
// #285 AC6: a peer with no provider quota (Pi, local) still shows what the hub counted for it in the last 5 h, as information, never as a meter or a gate.
|
|
2259
|
+
const hubTokenWindows = new Map<PeerId, TokenWindow>();
|
|
2260
|
+
const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
|
|
2261
|
+
const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
|
|
2262
|
+
// Replayed through `seed`, which skips rows older than 5 h before inserting any: a long-lived project's whole log costs one pass, not one search per row.
|
|
2263
|
+
const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
|
|
2264
|
+
for (const e of startupEvents) if (e.type === "tokens" && noQuotaPeer(e.peer)) (seedRows.get(e.peer) ?? seedRows.set(e.peer, []).get(e.peer)!).push({ at: Date.parse(e.at), n: e.n });
|
|
2265
|
+
for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
|
|
2266
|
+
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
2267
|
+
if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
|
|
2040
2268
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
2041
2269
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
2042
2270
|
if (n > 0) {
|
|
2043
|
-
event({ type: "tokens", peer, n, ...taskAttribution(peer) });
|
|
2271
|
+
event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
|
|
2044
2272
|
const turn = turns.get(peer);
|
|
2045
2273
|
if (turn) turn.tokens += n;
|
|
2046
2274
|
}
|
|
2047
2275
|
return n;
|
|
2048
2276
|
};
|
|
2049
|
-
const onKimiTokens = (total: number, session: string, peer: string) => {
|
|
2050
|
-
const n = addTokens(peer, delta(peer, total, session));
|
|
2051
|
-
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
2052
|
-
const now = Date.now();
|
|
2053
|
-
kimiTokens.push({ at: now, n });
|
|
2054
|
-
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
2055
|
-
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
2056
|
-
budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
|
|
2057
|
-
};
|
|
2058
2277
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
2059
2278
|
let claudeUsageSeen = 0;
|
|
2060
2279
|
let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
|
|
@@ -2085,7 +2304,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2085
2304
|
try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
|
|
2086
2305
|
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
2087
2306
|
for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
|
|
2088
|
-
for (const row of bus.queueList()) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
|
|
2307
|
+
for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
|
|
2089
2308
|
feedBudgetPauses();
|
|
2090
2309
|
supervision.checkRound();
|
|
2091
2310
|
try {
|
|
@@ -2256,10 +2475,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2256
2475
|
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
2257
2476
|
}
|
|
2258
2477
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2259
|
-
//
|
|
2478
|
+
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
2260
2479
|
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2261
2480
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2262
|
-
// completed-change notice) and
|
|
2481
|
+
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2263
2482
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
2264
2483
|
const target = board.get(Number(a.id));
|
|
2265
2484
|
if (target && !tasks.isPii(target)) throw new PreEffectToolRefusal(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
|
|
@@ -2281,9 +2500,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2281
2500
|
if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
|
|
2282
2501
|
}
|
|
2283
2502
|
if (MEMORY_READ_TOOLS.has(op)) {
|
|
2503
|
+
validateMemoryRead(op, a);
|
|
2284
2504
|
need("memory_read", "read shared memory");
|
|
2505
|
+
if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2506
|
+
checkMemory();
|
|
2507
|
+
const generation = connectionGeneration(by);
|
|
2508
|
+
const valid = () => may(by, "memory_read") && connectionGeneration(by) === generation && !turns.get(by)?.private && !contextPii(by);
|
|
2509
|
+
const symbolStarts = op === "hub_memory_graph" ? (a.starts as string[]).filter(node => node.startsWith("symbol:")) : [];
|
|
2510
|
+
const code = symbolStarts.length ? await memoryCode(symbolStarts, valid).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
2511
|
+
if (!valid()) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2285
2512
|
checkMemory();
|
|
2286
|
-
return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness());
|
|
2513
|
+
return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
|
|
2287
2514
|
}
|
|
2288
2515
|
if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
|
|
2289
2516
|
if (op === "hub_remember") need("remember", "save notes to shared memory");
|
|
@@ -2435,7 +2662,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2435
2662
|
case "hub_task_decline":
|
|
2436
2663
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
2437
2664
|
case "hub_task_done": {
|
|
2438
|
-
const t = await tasks.done(by, a.id, a.summary, a.refs);
|
|
2665
|
+
const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
|
|
2439
2666
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
2440
2667
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
2441
2668
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -2449,7 +2676,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2449
2676
|
}
|
|
2450
2677
|
case "hub_sdlc_record":
|
|
2451
2678
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2452
|
-
return line(tasks.
|
|
2679
|
+
return line(await tasks.recordSdlc(by, a.id, a.receipt));
|
|
2453
2680
|
case "hub_review":
|
|
2454
2681
|
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
|
|
2455
2682
|
case "hub_remember":
|
|
@@ -2468,7 +2695,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2468
2695
|
chmodSync(`${file}.tmp`, 0o600);
|
|
2469
2696
|
renameSync(`${file}.tmp`, file);
|
|
2470
2697
|
pending.done(summary);
|
|
2471
|
-
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project:
|
|
2698
|
+
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: memoryProject, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
|
|
2472
2699
|
notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
|
|
2473
2700
|
return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
|
|
2474
2701
|
}
|
|
@@ -2492,7 +2719,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2492
2719
|
const task = board.get(Number(a.id));
|
|
2493
2720
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2494
2721
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2495
|
-
|
|
2722
|
+
const peerDeliverables = await tasks.deliverableStatus(task);
|
|
2723
|
+
return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), ...(peerDeliverables ? { deliverables: peerDeliverables } : {}), flowRevision: task.history.length });
|
|
2496
2724
|
}
|
|
2497
2725
|
if (op === "route_explain" && by !== USER) {
|
|
2498
2726
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2528,13 +2756,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2528
2756
|
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2529
2757
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2530
2758
|
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2531
|
-
|
|
2759
|
+
const consoleDeliverables = await tasks.deliverableStatus(task);
|
|
2760
|
+
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
2761
|
+
const fresh = board.get(task.id);
|
|
2762
|
+
const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
|
|
2763
|
+
return JSON.stringify({ ...detail, relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2764
|
+
}
|
|
2765
|
+
case "deliverables": {
|
|
2766
|
+
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
2767
|
+
if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
|
|
2768
|
+
return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true }));
|
|
2532
2769
|
}
|
|
2533
2770
|
case "task_assign":
|
|
2534
2771
|
case "task_assign_checked": {
|
|
2535
2772
|
const peer = peerArg(a.peer, "peer");
|
|
2536
2773
|
if (!peer) throw new Error("peer is required");
|
|
2537
|
-
if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer") throw new Error("role must be owner or
|
|
2774
|
+
if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer" && a.role !== "planner") throw new Error("role must be owner, reviewer or planner");
|
|
2538
2775
|
if (op === "task_assign_checked" && (typeof a.revision !== "number" || !Number.isSafeInteger(a.revision) || a.revision < 0)) throw new Error("revision must be a nonnegative integer");
|
|
2539
2776
|
return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
|
|
2540
2777
|
}
|
|
@@ -2612,7 +2849,44 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2612
2849
|
else delete defaultSources[peer];
|
|
2613
2850
|
}
|
|
2614
2851
|
};
|
|
2615
|
-
|
|
2852
|
+
// Action allowances have their own host session port; adding local here must not change checkpoints, context telemetry or supervision.
|
|
2853
|
+
const actionSession = (peer: PeerId): string | undefined => {
|
|
2854
|
+
const owner = bus.peers.get(peer);
|
|
2855
|
+
if (isLocalPeer(owner)) { const id = owner.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
2856
|
+
return contextSession(peer);
|
|
2857
|
+
};
|
|
2858
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
|
|
2859
|
+
// #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
|
|
2860
|
+
const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
|
|
2861
|
+
task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
|
|
2862
|
+
nameable: text => tasks.nameable(text) }, peer);
|
|
2863
|
+
// A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
|
|
2864
|
+
// Claude: the launcher installed the PreToolUse hook (an `--unattended` launch skips every permission check, hooks included). ACP: a hub-owned
|
|
2865
|
+
// adapter with the gate, and only while its native mode is `ask`: Kimi's `yolo` and `auto` modes send no request for a call they allow.
|
|
2866
|
+
const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
|
|
2867
|
+
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
2868
|
+
const launch = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
|
|
2869
|
+
// The launch record outlives the process, so it proves nothing alone: the attached session must be the generation whose hook last spoke to the hub.
|
|
2870
|
+
if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
|
|
2871
|
+
if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
|
|
2872
|
+
if (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
|
|
2873
|
+
return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
|
|
2874
|
+
return { enforced: true };
|
|
2875
|
+
}
|
|
2876
|
+
const owner = bus.peers.get(peer);
|
|
2877
|
+
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
2878
|
+
const generation = owner?.sessionGeneration;
|
|
2879
|
+
return owner?.state === "busy" && typeof generation === "string" && generation.length > 0 ? { enforced: true } : { enforced: false, reason: "no current managed Claude implementer child" };
|
|
2880
|
+
}
|
|
2881
|
+
if (isCodexPeer(owner)) return { enforced: false, reason: owner.actionGated ? "Codex approval RPCs are gated, but native policy can execute without sending one (never-ask sends approvalPolicy never)" : "this Codex proxy has no hub approval gate" };
|
|
2882
|
+
if (isLocalPeer(owner)) return owner.state !== "offline" ? { enforced: true } : { enforced: false, reason: "the local worker is offline" };
|
|
2883
|
+
if (isPiPeer(owner)) return owner.state !== "offline" && owner.acceptingTools ? { enforced: true } : { enforced: false, reason: "Pi has no verified live tool bridge" };
|
|
2884
|
+
if (isAcpPeer(owner) && owner.actionGated) {
|
|
2885
|
+
const mode = owner.permissionModeState;
|
|
2886
|
+
return mode === "ask" ? { enforced: true } : { enforced: false, reason: mode === "unknown" || mode === "unmanaged" ? "its native approval mode is not managed by the hub, so a request may never be sent" : `its native mode ${mode} sends no request for a call it allows` };
|
|
2887
|
+
}
|
|
2888
|
+
return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
|
|
2889
|
+
};
|
|
2616
2890
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2617
2891
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2618
2892
|
const unattendedPeers = new Set<string>();
|
|
@@ -2632,8 +2906,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2632
2906
|
owner: row => {
|
|
2633
2907
|
const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
|
|
2634
2908
|
if (!proof) return undefined;
|
|
2909
|
+
const terminalRootCurrent = () => !declarations[row.peer] || definitionOf(row.peer)?.transport !== "claude-channel" || !config.roles[row.peer]?.includes("implementer") || (ownerWriteRoot(row.peer) === row.executionCwd && row.executionCwd !== opts.cwd);
|
|
2910
|
+
if (!terminalRootCurrent()) return undefined;
|
|
2635
2911
|
return { ...proof, isCurrent: () => {
|
|
2636
|
-
if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
|
|
2912
|
+
if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner || !terminalRootCurrent()) return false;
|
|
2637
2913
|
const current = owner?.terminalOwner?.(row.launchId);
|
|
2638
2914
|
return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
|
|
2639
2915
|
} };
|
|
@@ -2706,8 +2982,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2706
2982
|
if (value.instanceId !== instanceId) return {};
|
|
2707
2983
|
let currentLauncher = false;
|
|
2708
2984
|
try {
|
|
2709
|
-
const launch =
|
|
2710
|
-
if (launch
|
|
2985
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2986
|
+
if (launch) {
|
|
2711
2987
|
currentLauncher = true;
|
|
2712
2988
|
if (value.launchId !== launch.launchId) return {};
|
|
2713
2989
|
}
|
|
@@ -2806,7 +3082,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2806
3082
|
// filled one stays in the row and never matches.
|
|
2807
3083
|
const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
|
|
2808
3084
|
const integrity = (older = 0) => {
|
|
2809
|
-
const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
3085
|
+
const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
|
|
2810
3086
|
const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
|
|
2811
3087
|
const row: Record<string, unknown> = { ...t };
|
|
2812
3088
|
for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
|
|
@@ -2885,11 +3161,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2885
3161
|
instanceId,
|
|
2886
3162
|
version: VERSION,
|
|
2887
3163
|
pluginBundle: SERVER_JS,
|
|
2888
|
-
pendingRestart,
|
|
3164
|
+
pendingRestart: allPendingRestart(),
|
|
2889
3165
|
retainedConfig,
|
|
2890
3166
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
2891
3167
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
2892
3168
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
3169
|
+
memoryRead: memory.readStatus(),
|
|
2893
3170
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2894
3171
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
2895
3172
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
@@ -2904,6 +3181,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2904
3181
|
readOnlyPeers: extraIds.filter(readOnlyPeer),
|
|
2905
3182
|
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2906
3183
|
activeInstances: extraIds,
|
|
3184
|
+
ownerIsolation: Object.fromEntries(extraIds.map(id => [id, !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd])),
|
|
3185
|
+
ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => definitionOf(id)?.transport === "claude-channel" && !ownerWriteRoot(id)).map(id => [id, "terminal execution root not verified; owner admission refused"])),
|
|
2907
3186
|
stopping,
|
|
2908
3187
|
pid: process.pid,
|
|
2909
3188
|
cwd: opts.cwd,
|
|
@@ -2911,11 +3190,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2911
3190
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2912
3191
|
codexProxyPort: opts.codexProxyPort,
|
|
2913
3192
|
peers: Object.fromEntries(
|
|
2914
|
-
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), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(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 ?? [] } : {}) }]; }),
|
|
3193
|
+
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 }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(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 ?? [] } : {}) }]; }),
|
|
2915
3194
|
),
|
|
2916
3195
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2917
3196
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2918
|
-
actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id,
|
|
3197
|
+
actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
|
|
2919
3198
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2920
3199
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2921
3200
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -2969,7 +3248,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2969
3248
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
2970
3249
|
// message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
|
|
2971
3250
|
function heldRows(peer: PeerId) {
|
|
2972
|
-
return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
|
|
3251
|
+
return bus.queueList(peer, true).filter(row => row.state === "needs_review").map(row => ({
|
|
2973
3252
|
id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
|
|
2974
3253
|
kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
|
|
2975
3254
|
context: deliveryTaskContext(row.originals, board, peer),
|
|
@@ -2985,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2985
3264
|
if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
2986
3265
|
settlingSuperseded = true;
|
|
2987
3266
|
try {
|
|
2988
|
-
for (const row of bus.queueList()) {
|
|
3267
|
+
for (const row of bus.queueList(undefined, true)) {
|
|
2989
3268
|
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
2990
3269
|
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
2991
3270
|
if (!reason) continue;
|
|
@@ -3286,7 +3565,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3286
3565
|
const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
|
|
3287
3566
|
turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
3288
3567
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
3568
|
+
if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
|
|
3289
3569
|
} else if (!busy && open) {
|
|
3570
|
+
collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
|
|
3290
3571
|
turns.delete(e.peer);
|
|
3291
3572
|
let afterTurn: (() => void) | undefined;
|
|
3292
3573
|
let files: number | undefined;
|
|
@@ -3374,6 +3655,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3374
3655
|
try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
|
|
3375
3656
|
}
|
|
3376
3657
|
}
|
|
3658
|
+
if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
|
|
3659
|
+
// #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
|
|
3660
|
+
// session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
|
|
3661
|
+
if (e.state === "offline") actionBudgets.revoke(e.peer);
|
|
3377
3662
|
if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
|
|
3378
3663
|
else offlineSince.delete(e.peer);
|
|
3379
3664
|
// A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
|
|
@@ -3453,6 +3738,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3453
3738
|
const result = await requestPermission(req, signal);
|
|
3454
3739
|
return result.outcome === "answered" ? result.optionId : undefined;
|
|
3455
3740
|
}
|
|
3741
|
+
/** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
|
|
3742
|
+
async function classedToolPermission(peer: string, root: string, title: string, tool: string | undefined, input: unknown, signal: AbortSignal | undefined, current: () => boolean, observe?: ApprovalObserver): Promise<ToolApproval | undefined> {
|
|
3743
|
+
if (!tool || input === undefined) return undefined;
|
|
3744
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3745
|
+
// runTool executes in this host root and ignores model-supplied cwd/workdir fields.
|
|
3746
|
+
const effective = input && typeof input === "object" && !Array.isArray(input) ? { ...input, cwd: root, workdir: root } : input;
|
|
3747
|
+
let gate: ActionGateVerdict | undefined;
|
|
3748
|
+
try { gate = acpActionGate(peer)({ names: [tool], input: effective }, actionSession(peer) ?? "", title); }
|
|
3749
|
+
catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
|
|
3750
|
+
if (!gate) return undefined;
|
|
3751
|
+
const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
|
|
3752
|
+
if (gate.denyOnly) options.splice(0, 1);
|
|
3753
|
+
if (!gate.denyOnly && gate.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" });
|
|
3754
|
+
const picked = await requestPermission({ peer, title: gate.title, tool, classed: true, options }, signal, observe);
|
|
3755
|
+
if (picked.outcome === "expired") return "expired";
|
|
3756
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3757
|
+
if (picked.outcome !== "answered") return false;
|
|
3758
|
+
if (!gate.denyOnly && picked.optionId === ACTION_BUDGET_OPTION) {
|
|
3759
|
+
try { return gate.budget?.approve() === true; } catch { return false; }
|
|
3760
|
+
}
|
|
3761
|
+
return !gate.denyOnly && picked.optionId === "allow";
|
|
3762
|
+
}
|
|
3456
3763
|
async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
|
|
3457
3764
|
const requestEpoch = permissionAnswerEpoch(req.peer);
|
|
3458
3765
|
const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
|
|
@@ -3460,7 +3767,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3460
3767
|
catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
|
|
3461
3768
|
};
|
|
3462
3769
|
if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
|
|
3463
|
-
|
|
3770
|
+
// Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
|
|
3771
|
+
if (opts.unattended && !req.classed) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
|
|
3464
3772
|
const id = randomUUID().slice(0, 8);
|
|
3465
3773
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
3466
3774
|
// could repaint the terminal line, so everything but newline and tab is made visible.
|
|
@@ -3676,7 +3984,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3676
3984
|
const owner = bus.peers.get(peer);
|
|
3677
3985
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3678
3986
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3679
|
-
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3987
|
+
if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3680
3988
|
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3681
3989
|
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3682
3990
|
if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
|
|
@@ -3852,9 +4160,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3852
4160
|
}
|
|
3853
4161
|
if (adapter?.start.acpCommand) {
|
|
3854
4162
|
const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
|
|
4163
|
+
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
3855
4164
|
const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
|
|
3856
4165
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3857
4166
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
4167
|
+
actionGate: acpActionGate(peer),
|
|
3858
4168
|
cmd, kind: adapter.kind,
|
|
3859
4169
|
permissionMode: permissionMode(peer),
|
|
3860
4170
|
...(args.model ? { launchModel: args.model } : {}),
|
|
@@ -3868,7 +4178,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3868
4178
|
// session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
|
|
3869
4179
|
autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
|
|
3870
4180
|
log,
|
|
3871
|
-
onTokens: (total, session) => onKimiTokens(total, session, peer),
|
|
3872
4181
|
// Pause synchronously before the adapter requeues a provider-refused prompt (#306).
|
|
3873
4182
|
onQuotaRefusal: (window) => {
|
|
3874
4183
|
budget.report(peer, [window], true);
|
|
@@ -3879,6 +4188,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3879
4188
|
// adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
|
|
3880
4189
|
// shape reports a null reading: unknown, not the previous value (#285).
|
|
3881
4190
|
onUsageDiagnostic: (observation, sessionId) => {
|
|
4191
|
+
if (observation.source === "usage_update") collectKimiUsage(peer); // phase 2: the update is the cue to read the wire log, never a token total
|
|
3882
4192
|
if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
|
|
3883
4193
|
const current = contextSession(peer);
|
|
3884
4194
|
if (!current) return;
|
|
@@ -3904,9 +4214,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3904
4214
|
userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
|
|
3905
4215
|
const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
|
|
3906
4216
|
const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
|
|
4217
|
+
actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
|
|
3907
4218
|
const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
|
|
3908
4219
|
onActivity: item => activityRings[peer]?.append(item),
|
|
3909
4220
|
isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
|
|
4221
|
+
actionGate: acpActionGate(peer),
|
|
3910
4222
|
kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
|
|
3911
4223
|
...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
|
|
3912
4224
|
...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
|
|
@@ -3939,9 +4251,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3939
4251
|
if (adapter?.start.daemonPort === "codex-appserver") {
|
|
3940
4252
|
if (args.unattended && permissionMode(peer) !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask first, or restart without --unattended" };
|
|
3941
4253
|
let codexSteering = false;
|
|
4254
|
+
actionBudgets.revoke(peer);
|
|
3942
4255
|
const codex: CodexPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["codex-appserver"]({
|
|
3943
4256
|
admitAttach: () => membershipAllowed(peer),
|
|
3944
4257
|
permissionMode: permissionMode(peer),
|
|
4258
|
+
actionGate: acpActionGate(peer), onPermission,
|
|
3945
4259
|
onTokens: (added) => void addTokens(peer, added),
|
|
3946
4260
|
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
3947
4261
|
// 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).
|
|
@@ -4040,7 +4354,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4040
4354
|
let piReply: Envelope | undefined;
|
|
4041
4355
|
// Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
|
|
4042
4356
|
const piAlways = new Set<string>();
|
|
4043
|
-
|
|
4357
|
+
actionBudgets.revoke(peer);
|
|
4358
|
+
const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4359
|
+
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4360
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
|
|
4361
|
+
if (classed !== undefined) return classed;
|
|
4044
4362
|
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4045
4363
|
const selected = permissionMode(peer);
|
|
4046
4364
|
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
@@ -4090,8 +4408,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4090
4408
|
const nativeTurn = pi.observationTurn;
|
|
4091
4409
|
const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
|
|
4092
4410
|
...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
|
|
4093
|
-
permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
|
|
4094
|
-
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
|
|
4411
|
+
permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
|
|
4412
|
+
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
|
|
4095
4413
|
return picked; // never turn a nonempty expiry/abort string into authorization
|
|
4096
4414
|
},
|
|
4097
4415
|
});
|
|
@@ -4201,9 +4519,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4201
4519
|
if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
4202
4520
|
if (existing) { mute(existing); await existing.stop(); }
|
|
4203
4521
|
const capture = config.memory.enabled
|
|
4204
|
-
? new Capture(memory, { project:
|
|
4522
|
+
? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
|
|
4205
4523
|
: undefined;
|
|
4206
|
-
|
|
4524
|
+
actionBudgets.revoke(peer);
|
|
4525
|
+
const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4526
|
+
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4527
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
|
|
4528
|
+
if (classed !== undefined) return classed;
|
|
4207
4529
|
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4208
4530
|
const selected = permissionMode(peer);
|
|
4209
4531
|
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
@@ -4256,6 +4578,51 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4256
4578
|
await local.start();
|
|
4257
4579
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
4258
4580
|
}
|
|
4581
|
+
if (adapter?.start.daemonPort === "claude-implementer") {
|
|
4582
|
+
if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
|
|
4583
|
+
const currentRoot = () => {
|
|
4584
|
+
const roles = config.roles[peer] ?? [];
|
|
4585
|
+
return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveInstanceExecutionCwd(opts.cwd, peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
|
|
4586
|
+
};
|
|
4587
|
+
try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
|
|
4588
|
+
catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
|
|
4589
|
+
if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
|
|
4590
|
+
if (existing) { mute(existing); await existing.stop(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
|
|
4591
|
+
const selected = permissionMode(peer);
|
|
4592
|
+
if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
|
|
4593
|
+
const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
|
|
4594
|
+
cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
|
|
4595
|
+
factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
|
|
4596
|
+
admit: env => {
|
|
4597
|
+
try {
|
|
4598
|
+
if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
|
|
4599
|
+
if (env.refs?.task === undefined) return true;
|
|
4600
|
+
const task = board.get(Number(env.refs.task));
|
|
4601
|
+
return !!task && task.owner === peer && !tasks.isPii(task) && ["proposed", "in_progress", "changes_requested"].includes(task.state);
|
|
4602
|
+
} catch { return false; }
|
|
4603
|
+
},
|
|
4604
|
+
chatTask: () => implementerChatTask(peer),
|
|
4605
|
+
transcriptPersisted: (task, session) => {
|
|
4606
|
+
const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
|
|
4607
|
+
return path ? pathEvidence(path, lstatSync) : "unknown";
|
|
4608
|
+
},
|
|
4609
|
+
onUsage: (usage, task) => {
|
|
4610
|
+
if (bus.peers.get(peer) !== implementer) return;
|
|
4611
|
+
const normalized = normalizeUsage(usage), total = claudeReportedTokens(normalized);
|
|
4612
|
+
if (total !== undefined) addTokens(peer, total, task);
|
|
4613
|
+
event({ type: "usage", peer, source: "claude_result", id: `${runId}:${peer}:${++codexUsageSeq}`, ...normalized,
|
|
4614
|
+
...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
|
|
4615
|
+
}, notify,
|
|
4616
|
+
}) });
|
|
4617
|
+
await implementer.start();
|
|
4618
|
+
try {
|
|
4619
|
+
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4620
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4621
|
+
if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
|
|
4622
|
+
executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
|
|
4623
|
+
return { ok: true };
|
|
4624
|
+
} catch (error) { await implementer.stop().catch(() => {}); throw error; }
|
|
4625
|
+
}
|
|
4259
4626
|
if (adapter?.reviewer) {
|
|
4260
4627
|
const backend = adapter.reviewer.backend;
|
|
4261
4628
|
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
@@ -4381,7 +4748,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4381
4748
|
const summary = bus.queueSummary(peer);
|
|
4382
4749
|
return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
|
|
4383
4750
|
manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
|
|
4384
|
-
...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
|
|
4751
|
+
...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source, kind: w.kind, ...(w.superseded ? { superseded: true } : {}) })) } : {}) };
|
|
4385
4752
|
}),
|
|
4386
4753
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
4387
4754
|
};
|
|
@@ -4478,9 +4845,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4478
4845
|
entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
|
|
4479
4846
|
} catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
|
|
4480
4847
|
}
|
|
4848
|
+
async function uiMemorySnapshot(after: number, input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4849
|
+
const query = input?.memory as Record<string, unknown> | undefined;
|
|
4850
|
+
const start = input?.view === "memory" && query && query.codeNeighbours === true && typeof query.node === "string" && query.node.startsWith("symbol:") && Buffer.byteLength(query.node) <= 512 ? query.node : undefined;
|
|
4851
|
+
const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
4852
|
+
const snapshot = uiSnapshot(after, input, session);
|
|
4853
|
+
if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
|
|
4854
|
+
const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
|
|
4855
|
+
const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
|
|
4856
|
+
published.notes = published.notes.filter(note => note.tier === tier && (!query?.kind || note.kind === query.kind) && (!query?.peer || note.peer === query.peer) && (!query?.machine || note.machine === query.machine)
|
|
4857
|
+
&& (!query?.since || typeof query.since !== "string" || !note.at || note.at >= query.since)
|
|
4858
|
+
&& (!search || (() => { const row = memoryStore?.get(note.id.slice(4), memoryProject); return !!row && `${row.title ?? ""} ${row.text}`.toLowerCase().includes(search); })()));
|
|
4859
|
+
Object.assign(snapshot.memory, { code: published });
|
|
4860
|
+
}
|
|
4861
|
+
return snapshot;
|
|
4862
|
+
}
|
|
4481
4863
|
function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4482
4864
|
const quota = budget.status();
|
|
4483
4865
|
const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
|
|
4866
|
+
const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
|
|
4484
4867
|
// #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
|
|
4485
4868
|
// so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
|
|
4486
4869
|
const turnView = (peer: PeerId) => {
|
|
@@ -4545,7 +4928,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4545
4928
|
instanceId,
|
|
4546
4929
|
attention: attentionStatus.attentionInput,
|
|
4547
4930
|
pet,
|
|
4548
|
-
status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4931
|
+
status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4549
4932
|
boardCapabilities: { backlog: true },
|
|
4550
4933
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
4551
4934
|
releases: tasks.releaseList(USER),
|
|
@@ -4558,10 +4941,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4558
4941
|
tasks: boardTasks.map((task) => {
|
|
4559
4942
|
const view = tasks.publicView(task);
|
|
4560
4943
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4561
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4944
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}), ...(() => { const deliverables = tasks.deliverableBadge(task); return deliverables ? { deliverables } : {}; })() };
|
|
4562
4945
|
}),
|
|
4563
4946
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4564
|
-
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))) }])),
|
|
4947
|
+
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), ...(budgetNoteOf.get(id) ? { note: budgetNoteOf.get(id)!.note, noteKind: budgetNoteOf.get(id)!.kind } : {}), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
4565
4948
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
4566
4949
|
const req = JSON.parse(push);
|
|
4567
4950
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -4618,7 +5001,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4618
5001
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4619
5002
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4620
5003
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4621
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
5004
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled), peer => plannerEligible.has(peer));
|
|
4622
5005
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4623
5006
|
}
|
|
4624
5007
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -4674,7 +5057,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4674
5057
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4675
5058
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4676
5059
|
}
|
|
4677
|
-
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) };
|
|
5060
|
+
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), structural: !!scope && structuralScopes.has(scope) };
|
|
4678
5061
|
const decision = authorizeConfigLeaf(input);
|
|
4679
5062
|
if (!decision.allowed) {
|
|
4680
5063
|
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" : ""}`;
|
|
@@ -4854,7 +5237,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4854
5237
|
/** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
|
|
4855
5238
|
// ponytail: reads the two config files and the undo record and parses the routing files on each dashboard poll; cache by mtime if a page ever feels it.
|
|
4856
5239
|
function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
|
|
4857
|
-
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
5240
|
+
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
|
|
4858
5241
|
catch { return {}; }
|
|
4859
5242
|
}
|
|
4860
5243
|
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
|
|
@@ -4875,8 +5258,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4875
5258
|
},
|
|
4876
5259
|
});
|
|
4877
5260
|
const undo = pendingUndo(opts.stateDir);
|
|
4878
|
-
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it
|
|
4879
|
-
return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
5261
|
+
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it pins the listed peers as the only planners; new tasks select one of those holders. Agents cannot change this setting.`;
|
|
5262
|
+
return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
4880
5263
|
}
|
|
4881
5264
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
4882
5265
|
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
@@ -5114,6 +5497,46 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5114
5497
|
return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
|
|
5115
5498
|
}
|
|
5116
5499
|
|
|
5500
|
+
// #689: structural config edits (src/hub/config-edit-host.ts). The editor decides authority, the typed confirmation and the audit event;
|
|
5501
|
+
// the daemon supplies the loaders' verdict, the hold and the one scoped reload path. A structural key is allowed through reload authority
|
|
5502
|
+
// only while its own scope is registered here, for the duration of that one reload.
|
|
5503
|
+
configEditor = createConfigEditor({
|
|
5504
|
+
cwd: opts.cwd, stateDir: opts.stateDir,
|
|
5505
|
+
check: (file, text) => {
|
|
5506
|
+
if (file !== "config.local.json") return;
|
|
5507
|
+
const texts = configSourceTexts(opts.cwd);
|
|
5508
|
+
texts["config.local.json"] = text;
|
|
5509
|
+
const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
|
|
5510
|
+
const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
|
|
5511
|
+
// Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
|
|
5512
|
+
checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
|
|
5513
|
+
},
|
|
5514
|
+
held: () => stopping || recoveryActive() ? "recovery or shutdown" : undefined,
|
|
5515
|
+
reload: async (authority, key) => {
|
|
5516
|
+
structuralScopes.add(key);
|
|
5517
|
+
try { return await reloadConfig(authority, key); } finally { structuralScopes.delete(key); }
|
|
5518
|
+
},
|
|
5519
|
+
event: entry => event(entry),
|
|
5520
|
+
notify: text => notify(text),
|
|
5521
|
+
changed: () => writeStatus(),
|
|
5522
|
+
pending: () => pendingRestart,
|
|
5523
|
+
effective: path => valueAt(config, path),
|
|
5524
|
+
runningSdlc: () => ({ mode: lifecycleConfig.mode, requiredChecks: lifecycleConfig.requiredChecks }),
|
|
5525
|
+
// Status and the settings view poll this: read sdlc.toml again only when its stamp changed. ponytail: the stamp is mtime, ctime, inode and size, so an in-place rewrite of the same size inside one filesystem timestamp tick is missed until the next change; it feeds display only (a status line), never an authority. A watcher is the upgrade if a poll ever shows.
|
|
5526
|
+
fileSdlc: () => {
|
|
5527
|
+
const stamp = (() => { try { const info = statSync(join(opts.cwd, ".agenthub", "sdlc.toml")); return `${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.size}`; } catch { return "-"; } })();
|
|
5528
|
+
if (sdlcFileCache?.stamp !== stamp) {
|
|
5529
|
+
let value: { mode: string; requiredChecks: readonly string[] } | string;
|
|
5530
|
+
sdlcFileReads++;
|
|
5531
|
+
try { const loaded = loadSdlc(opts.cwd, VERSION); value = { mode: loaded.mode, requiredChecks: loaded.requiredChecks }; } catch (error) { value = sanitize((error as Error).message).slice(0, 300); }
|
|
5532
|
+
sdlcFileCache = { stamp, value };
|
|
5533
|
+
}
|
|
5534
|
+
return sdlcFileCache.value;
|
|
5535
|
+
},
|
|
5536
|
+
declared: () => Object.fromEntries(Object.entries(declarations).map(([id, value]) => [id, { ...value }])),
|
|
5537
|
+
localConfig: () => { try { const text = readEditable(opts.cwd, "config.local.json"); const doc = text === null ? {} : JSON.parse(text); return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {}; } catch { return {}; } },
|
|
5538
|
+
});
|
|
5539
|
+
|
|
5117
5540
|
async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
|
|
5118
5541
|
const bad = { ok: false, error: "invalid or unavailable dashboard action" };
|
|
5119
5542
|
// #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
|
|
@@ -5199,6 +5622,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5199
5622
|
return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
|
|
5200
5623
|
case "setting_reload":
|
|
5201
5624
|
return reloadConfig(authority);
|
|
5625
|
+
case "config_edit_preview":
|
|
5626
|
+
return configEditor!.preview(a.edit, authority);
|
|
5627
|
+
case "config_edit_apply":
|
|
5628
|
+
// A dashboard session is never given the typed confirmation of an executable key: that is the console's, and refused here by the editor.
|
|
5629
|
+
return configEditor!.apply(a.edit, authority, undefined, a.reason);
|
|
5202
5630
|
default:
|
|
5203
5631
|
return bad;
|
|
5204
5632
|
}
|
|
@@ -5245,6 +5673,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5245
5673
|
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";
|
|
5246
5674
|
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";
|
|
5247
5675
|
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";
|
|
5676
|
+
if (extraIds.some(id => adapterCatalog.definitionOf(declarations[id]!.kind)?.transport === "claude-channel" && declarations[id]!.roles?.includes("implementer")) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("role_stages"))) return "target lacks role_stages; declared Claude implementer authority cannot be restored by that daemon";
|
|
5677
|
+
if ((Array.isArray(config.planner) || typeof config.planner === "string" && extraIds.includes(config.planner) || extraIds.some(id => declarations[id]!.roles?.includes("planner")) || board.hasPlannerState())
|
|
5678
|
+
&& (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
|
|
5248
5679
|
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";
|
|
5249
5680
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
5250
5681
|
return undefined;
|
|
@@ -5366,11 +5797,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5366
5797
|
}
|
|
5367
5798
|
|
|
5368
5799
|
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
5372
|
-
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
5373
|
-
} catch { return undefined; }
|
|
5800
|
+
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
5801
|
+
return row?.permissionHook ? row : undefined;
|
|
5374
5802
|
}
|
|
5375
5803
|
async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
|
|
5376
5804
|
if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
|
|
@@ -5480,7 +5908,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5480
5908
|
|
|
5481
5909
|
function onMessage(sock: Sock, msg: any): void {
|
|
5482
5910
|
const c = sock.data;
|
|
5483
|
-
const reply = (body: Record<string, unknown>) => {
|
|
5911
|
+
const reply = (body: Record<string, unknown>) => {
|
|
5912
|
+
if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
|
|
5913
|
+
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
5914
|
+
};
|
|
5484
5915
|
if (!c.authed) {
|
|
5485
5916
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
5486
5917
|
if (msg.v !== PROTOCOL) {
|
|
@@ -5501,6 +5932,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5501
5932
|
c.peer = String(msg.peer ?? "");
|
|
5502
5933
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5503
5934
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5935
|
+
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
5936
|
+
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
5937
|
+
if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
|
|
5938
|
+
c.launchId = msg.launchId;
|
|
5939
|
+
c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
|
|
5940
|
+
if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
|
|
5941
|
+
}
|
|
5504
5942
|
// #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
|
|
5505
5943
|
// 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.
|
|
5506
5944
|
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
@@ -5517,8 +5955,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5517
5955
|
if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
|
|
5518
5956
|
let currentLaunch: unknown;
|
|
5519
5957
|
try {
|
|
5520
|
-
const launch =
|
|
5521
|
-
if (launch
|
|
5958
|
+
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5959
|
+
if (launch) currentLaunch = launch.launchId;
|
|
5522
5960
|
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
5523
5961
|
if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
|
|
5524
5962
|
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
@@ -5534,6 +5972,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5534
5972
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
5535
5973
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
5536
5974
|
ws.claim(sock, msg.channel === false);
|
|
5975
|
+
if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
5976
|
+
revokeTerminalExecution(c.peer);
|
|
5977
|
+
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5978
|
+
try {
|
|
5979
|
+
const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
|
|
5980
|
+
if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
|
|
5981
|
+
executionRoots.set(c.peer, root);
|
|
5982
|
+
executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
|
|
5983
|
+
}
|
|
5984
|
+
} catch { /* Unknown or changed worktree evidence never grants owner isolation. */ }
|
|
5985
|
+
}
|
|
5537
5986
|
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
|
|
5538
5987
|
if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
|
|
5539
5988
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
@@ -5548,7 +5997,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5548
5997
|
}
|
|
5549
5998
|
if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
|
|
5550
5999
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
6000
|
+
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
5551
6001
|
switch (msg.t) {
|
|
6002
|
+
case "permission_request": {
|
|
6003
|
+
const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
|
|
6004
|
+
if (!peer || !launch || c.role !== "tools" || typeof msg.tool !== "string" || !msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)) return void reply({ ok: false, behavior: "deny", message: "permission unavailable" });
|
|
6005
|
+
const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
|
|
6006
|
+
const pending = new AbortController(), abortPending = () => pending.abort();
|
|
6007
|
+
launch.signal.addEventListener("abort", abortPending, { once: true });
|
|
6008
|
+
// Heartbeats keep an approval alive; they grant nothing. Admission and the final
|
|
6009
|
+
// answer still revalidate native root evidence, without synchronous Git on every tick.
|
|
6010
|
+
const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
|
|
6011
|
+
c.toolsOwner?.owner.keepApprovalAlive?.();
|
|
6012
|
+
void requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal).then(result => {
|
|
6013
|
+
const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
|
|
6014
|
+
reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
|
|
6015
|
+
}, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
|
|
6016
|
+
return;
|
|
6017
|
+
}
|
|
5552
6018
|
case "permission_default": {
|
|
5553
6019
|
if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
|
|
5554
6020
|
if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
|
|
@@ -5624,6 +6090,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5624
6090
|
return void reply({ ok: true });
|
|
5625
6091
|
}
|
|
5626
6092
|
case "inbox": {
|
|
6093
|
+
if (c.toolsOwner) {
|
|
6094
|
+
const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
|
|
6095
|
+
try {
|
|
6096
|
+
// Inspect the still-pending batch before pull commits/removes it: private work never reaches this native child.
|
|
6097
|
+
const pending = bus.pendingEnvelopes(peer);
|
|
6098
|
+
if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
|
|
6099
|
+
if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
|
|
6100
|
+
const envs = bus.pull(peer);
|
|
6101
|
+
if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
|
|
6102
|
+
return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
6103
|
+
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
6104
|
+
}
|
|
5627
6105
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
5628
6106
|
// Pull and push share holds and journal settlement; work read here is completed, never pushed again.
|
|
5629
6107
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
@@ -5792,6 +6270,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5792
6270
|
};
|
|
5793
6271
|
try {
|
|
5794
6272
|
const peer = c.peer;
|
|
6273
|
+
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
6274
|
+
const launch = currentImplementerTools(c), root = ownerWriteRoot(peer);
|
|
6275
|
+
if (!launch || !root || root === opts.cwd || sessionId !== launch.sessionId || msg.nativeLaunchId !== launch.launchId || msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
6276
|
+
if (typeof msg.transcriptPath === "string" && isAbsolute(msg.transcriptPath) && basename(msg.transcriptPath) === `${launch.sessionId}.jsonl`) {
|
|
6277
|
+
try {
|
|
6278
|
+
const projects = realPath(join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects")), transcript = realPath(msg.transcriptPath);
|
|
6279
|
+
if (transcript.startsWith(`${projects}/`) && statSync(transcript).isFile() && launch.task !== undefined) implementerTranscripts.set(`${peer}:${launch.task}:${launch.sessionId}`, transcript);
|
|
6280
|
+
} catch { /* Exact path is not yet present or is unreadable: unknown preserves session identity. */ }
|
|
6281
|
+
}
|
|
6282
|
+
const mode = permissionMode(peer);
|
|
6283
|
+
if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
|
|
6284
|
+
const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
|
|
6285
|
+
const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
|
|
6286
|
+
return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
|
|
6287
|
+
}
|
|
5795
6288
|
let nativeClaude = false;
|
|
5796
6289
|
if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
|
|
5797
6290
|
let expectedLaunch: string | undefined;
|
|
@@ -5800,8 +6293,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5800
6293
|
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
|
|
5801
6294
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
5802
6295
|
try {
|
|
5803
|
-
const launch =
|
|
5804
|
-
if (launch
|
|
6296
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
6297
|
+
if (launch) expectedLaunch = launch.launchId;
|
|
5805
6298
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
5806
6299
|
const current = claudeSession(peer);
|
|
5807
6300
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
@@ -5818,10 +6311,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5818
6311
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
5819
6312
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5820
6313
|
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5821
|
-
const
|
|
6314
|
+
const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
|
|
6315
|
+
if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
|
|
6316
|
+
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
|
|
5822
6317
|
if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5823
|
-
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
5824
|
-
|
|
6318
|
+
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
6319
|
+
// Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
|
|
6320
|
+
const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
|
|
6321
|
+
const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {};
|
|
5825
6322
|
// Permission-only hooks carry no native-turn evidence and must never drive busy state.
|
|
5826
6323
|
const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
|
|
5827
6324
|
const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
|
|
@@ -5965,7 +6462,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5965
6462
|
case "ui":
|
|
5966
6463
|
if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
|
|
5967
6464
|
try {
|
|
5968
|
-
dashboard ??= startDashboard({ snapshot:
|
|
6465
|
+
dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
|
|
5969
6466
|
writeStatus();
|
|
5970
6467
|
// #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
|
|
5971
6468
|
if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
|
|
@@ -6054,7 +6551,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6054
6551
|
case "ui_snapshot":
|
|
6055
6552
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
6056
6553
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
6057
|
-
|
|
6554
|
+
void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
|
|
6555
|
+
return;
|
|
6058
6556
|
case "ui_action":
|
|
6059
6557
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
6060
6558
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -6069,7 +6567,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6069
6567
|
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
6070
6568
|
case "settings_get":
|
|
6071
6569
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6072
|
-
return void reply({ ok: true, ...settingsView(), roster: rosterView() });
|
|
6570
|
+
return void reply({ ok: true, ...settingsView(), roster: rosterView(), configEdit: (() => { const view = configEditor!.view(); return { revisions: view.revisions, known: view.known }; })() });
|
|
6073
6571
|
case "settings_reload":
|
|
6074
6572
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
|
|
6075
6573
|
return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
|
|
@@ -6083,6 +6581,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6083
6581
|
case "settings_undo":
|
|
6084
6582
|
if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
|
|
6085
6583
|
return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
|
|
6584
|
+
case "config_edit_get":
|
|
6585
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6586
|
+
return void reply({ ok: true, ...configEditor!.view() });
|
|
6587
|
+
case "config_edit_preview":
|
|
6588
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6589
|
+
return void reply(configEditor!.preview(msg.edit, "terminal"));
|
|
6590
|
+
case "config_edit_apply":
|
|
6591
|
+
if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
|
|
6592
|
+
void configEditor!.apply(msg.edit, "terminal", msg.confirm, msg.reason).then(reply, () => reply({ ok: false, error: "the config edit could not be completed; inspect hub.log" }));
|
|
6593
|
+
return;
|
|
6086
6594
|
case "kimi_launch_end": {
|
|
6087
6595
|
if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
|
|
6088
6596
|
if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
|
|
@@ -6132,8 +6640,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6132
6640
|
});
|
|
6133
6641
|
return;
|
|
6134
6642
|
case "task":
|
|
6643
|
+
if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
|
|
6135
6644
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
6136
|
-
taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
|
|
6645
|
+
(c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
|
|
6137
6646
|
(text) => reply({ t: "task", ok: true, text }),
|
|
6138
6647
|
(e: Error) => reply({ t: "task", ok: false, error: e.message }),
|
|
6139
6648
|
);
|
|
@@ -6171,7 +6680,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6171
6680
|
// Screened before the caps: withheld entries never crowd out readable evidence.
|
|
6172
6681
|
const hits = screenMemory(memoryStore!.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER })).map((v) => [v, score(v)] as const).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([v]) => v);
|
|
6173
6682
|
const near = screenMemory(memoryStore!.related(memoryProject, [...new Set(hits.flatMap((v) => v.about))], Number.MAX_SAFE_INTEGER)).slice(0, 6);
|
|
6174
|
-
return [...hits, ...near].map((v) => ({ id: `mem:${v.id
|
|
6683
|
+
return [...hits, ...near].map((v) => ({ id: `mem:${v.id}`, text: memoryLine(v).slice(13) }));
|
|
6175
6684
|
} } : {}),
|
|
6176
6685
|
project: chain.at(-1)!,
|
|
6177
6686
|
logFile,
|
|
@@ -6191,7 +6700,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6191
6700
|
project: chain.at(-1)!,
|
|
6192
6701
|
metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
|
|
6193
6702
|
});
|
|
6194
|
-
saved = ok ? "saved
|
|
6703
|
+
saved = ok ? "saved as a candidate model-written answer; a person must review it" : "memory store unavailable; nothing saved";
|
|
6195
6704
|
}
|
|
6196
6705
|
}
|
|
6197
6706
|
reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
|
|
@@ -6201,7 +6710,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6201
6710
|
case "budget":
|
|
6202
6711
|
if (c.role !== "console") {
|
|
6203
6712
|
if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
|
|
6204
|
-
return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), gate: config.budget.gate });
|
|
6713
|
+
return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6205
6714
|
}
|
|
6206
6715
|
if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
|
|
6207
6716
|
if (msg.resume) {
|
|
@@ -6210,9 +6719,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6210
6719
|
const used = Number(msg.set.used);
|
|
6211
6720
|
if (!bus.peers.has(String(msg.set.peer)) && !budget.record(String(msg.set.peer))) return void reply({ t: "budget", ok: false, error: `unknown peer: ${msg.set.peer}` });
|
|
6212
6721
|
if (!(used >= 0 && used <= 1)) return void reply({ t: "budget", ok: false, error: "used must be between 0 and 1" });
|
|
6213
|
-
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" }]);
|
|
6722
|
+
budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", kind: "manual", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
|
|
6214
6723
|
}
|
|
6215
|
-
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6724
|
+
return void reply({ t: "budget", ok: true, budget: budget.status(), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6216
6725
|
case "usage": {
|
|
6217
6726
|
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6218
6727
|
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
@@ -6317,6 +6826,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6317
6826
|
executionBudget.close();
|
|
6318
6827
|
syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
|
|
6319
6828
|
await syncRun?.catch(() => undefined);
|
|
6829
|
+
memory.close();
|
|
6320
6830
|
candidates?.close();
|
|
6321
6831
|
memoryStore?.close();
|
|
6322
6832
|
board.close();
|
|
@@ -6393,7 +6903,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6393
6903
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
6394
6904
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
6395
6905
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6396
|
-
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6906
|
+
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6397
6907
|
} finally {
|
|
6398
6908
|
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6399
6909
|
}
|