@staix/agent-hub 0.15.0 → 0.17.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 +43 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +24 -8
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +15 -3
- package/docs/agent-notes/tests.md +8 -3
- package/docs/dogfood.md +18 -6
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +160 -47
- package/docs/quickstart.md +13 -5
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +11 -11
- package/docs/security.md +94 -22
- package/docs/smoke.md +58 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- 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/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verified.json +30 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3421 -2176
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +9 -4
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +20 -2
- package/src/cli/console-symbols.ts +3 -2
- package/src/cli/console-usage.ts +5 -2
- package/src/cli/console.ts +5 -1
- package/src/cli/help.ts +7 -5
- package/src/cli/identity.ts +3 -1
- package/src/cli/main.ts +44 -10
- package/src/cli/memory.ts +119 -7
- package/src/cli/output.ts +12 -4
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/action-class.ts +211 -17
- package/src/hub/action-enforce.ts +13 -5
- package/src/hub/action-policy.ts +19 -8
- package/src/hub/ask.ts +16 -4
- package/src/hub/board.ts +86 -31
- package/src/hub/budget-notes.ts +65 -0
- package/src/hub/budget.ts +42 -10
- package/src/hub/bus.ts +1 -1
- package/src/hub/code-facts.ts +56 -16
- package/src/hub/conductor.ts +5 -2
- package/src/hub/config-apply.ts +7 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +384 -71
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/kimi-usage.ts +12 -0
- package/src/hub/lifecycle.ts +10 -5
- 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 +11 -27
- package/src/hub/memory-stale-anchors.ts +159 -0
- package/src/hub/memory-symbols.ts +74 -0
- package/src/hub/planning.ts +10 -4
- package/src/hub/read-tools.ts +21 -3
- package/src/hub/sdlc.ts +34 -0
- package/src/hub/settings.ts +17 -2
- package/src/hub/settled-roles.ts +15 -3
- package/src/hub/task-sweep.ts +25 -3
- package/src/hub/tasks.ts +378 -76
- package/src/hub/token-window.ts +15 -2
- package/src/hub/window-kind.ts +13 -0
- package/src/local/tools.ts +5 -5
- package/src/memory/brief.ts +66 -16
- 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 +100 -18
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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";
|
|
1
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
5
|
import { normalizeUsage } from "../omniroute/usage.ts";
|
|
3
6
|
import { permissionPayload } from "./permission-payload.ts";
|
|
@@ -21,7 +24,7 @@ import { renderHeldHint } from "./delivery-hints.ts";
|
|
|
21
24
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
22
25
|
import { MemoryObservations } from "./memory-observations.ts";
|
|
23
26
|
import { createStalenessProbe } from "./memory-staleness.ts";
|
|
24
|
-
import { matchingPlanFails, planGuardNodes } from "./memory-plan-guard.ts";
|
|
27
|
+
import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
|
|
25
28
|
import { planningWipLimits } from "./planning.ts";
|
|
26
29
|
import { projectedDiff } from "./projected-diff.ts";
|
|
27
30
|
import { listenWithoutPortCollision } from "./listen.ts";
|
|
@@ -43,7 +46,7 @@ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } fro
|
|
|
43
46
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
44
47
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
45
48
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
46
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
49
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
|
|
47
50
|
import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
48
51
|
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
49
52
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
@@ -67,13 +70,16 @@ import { createConfigEditor } from "./config-edit-host.ts";
|
|
|
67
70
|
import { readEditable } from "./config-edit.ts";
|
|
68
71
|
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
69
72
|
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
73
|
+
import { TokenWindow } from "./token-window.ts";
|
|
74
|
+
import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
|
|
70
75
|
import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
|
|
71
76
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
72
77
|
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
73
78
|
import type { ServerWebSocket } from "bun";
|
|
74
|
-
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";
|
|
75
80
|
import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
|
|
76
81
|
import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
|
|
82
|
+
import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
|
|
77
83
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
78
84
|
import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
|
|
79
85
|
import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
|
|
@@ -84,11 +90,11 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
84
90
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
85
91
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
86
92
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
87
|
-
import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
88
|
-
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
93
|
+
import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
94
|
+
import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
|
|
89
95
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
90
96
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
91
|
-
import { Capture
|
|
97
|
+
import { Capture } from "../memory/capture.ts";
|
|
92
98
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
93
99
|
import { Briefs } from "../memory/brief.ts";
|
|
94
100
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
@@ -120,7 +126,7 @@ import { codeGroup } from "./task-code-group.ts";
|
|
|
120
126
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
121
127
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
122
128
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
123
|
-
import { MemoryClient
|
|
129
|
+
import { MemoryClient } from "../memory/client.ts";
|
|
124
130
|
import { VERSION } from "../version.ts";
|
|
125
131
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
126
132
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
@@ -144,10 +150,10 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
|
|
|
144
150
|
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
145
151
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
146
152
|
import { detectTooling } from "./tooling.ts";
|
|
147
|
-
import { looksSecret, DEFAULT_LEASE_MS,
|
|
153
|
+
import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
148
154
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
149
|
-
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates,
|
|
150
|
-
import { syncMemory } from "@staix/agent-mem";
|
|
155
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
156
|
+
import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
|
|
151
157
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
152
158
|
import { nativeRecall } from "../memory/recall.ts";
|
|
153
159
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
@@ -177,7 +183,7 @@ export interface HubConfig {
|
|
|
177
183
|
roster: PeerRoster;
|
|
178
184
|
roles: Record<string, string[]>;
|
|
179
185
|
/** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
|
|
180
|
-
planner?: string;
|
|
186
|
+
planner?: string | string[];
|
|
181
187
|
profile?: ProfileName;
|
|
182
188
|
profile_overrides?: Record<string, unknown>;
|
|
183
189
|
pii?: { paths: string[] };
|
|
@@ -286,11 +292,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
|
|
|
286
292
|
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"];
|
|
287
293
|
|
|
288
294
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
289
|
-
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
295
|
+
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[] } {
|
|
290
296
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
291
297
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
292
298
|
const notices: string[] = [];
|
|
293
|
-
let planner: string | undefined;
|
|
299
|
+
let planner: string | string[] | undefined;
|
|
294
300
|
for (const name of CONFIG_FILES) {
|
|
295
301
|
let file: Record<string, unknown>;
|
|
296
302
|
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
@@ -308,8 +314,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
316
|
if (file.planner !== undefined) {
|
|
311
|
-
if (
|
|
312
|
-
|
|
317
|
+
if (file.planner === null) throw new Error("planner takes a peer id or a nonempty list of up to eight ids");
|
|
318
|
+
const def = instanceSettingDef("planner", scope.instances, scope.catalog);
|
|
319
|
+
if (typeof def === "string") throw new Error(def);
|
|
320
|
+
const normalized = checkSettingValue(def, file.planner) as string | string[];
|
|
321
|
+
planner = Array.isArray(file.planner) ? [...file.planner] as string[] : normalized;
|
|
313
322
|
}
|
|
314
323
|
if (file.conductor !== undefined) {
|
|
315
324
|
if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
|
|
@@ -418,7 +427,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
418
427
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
419
428
|
}
|
|
420
429
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
421
|
-
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
430
|
+
const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
|
|
422
431
|
retired.push(...conductorPolicy.notices ?? []);
|
|
423
432
|
const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
424
433
|
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
|
|
@@ -501,6 +510,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
501
510
|
}
|
|
502
511
|
|
|
503
512
|
export interface DaemonOptions {
|
|
513
|
+
/** Host-only test/embedding port; never read from project configuration or a tool. */
|
|
514
|
+
memoryClient?: MemoryClient;
|
|
504
515
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
505
516
|
snapshot?: typeof snapshot;
|
|
506
517
|
snapshotBurstMs?: number;
|
|
@@ -583,6 +594,9 @@ export function resolutionRefusal(error: unknown): string {
|
|
|
583
594
|
return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
|
|
584
595
|
}
|
|
585
596
|
|
|
597
|
+
/** No remote set and a stop are not failures: agent-mem words the missing remote "no sync remote" (v1) or "no generation sync remote configured". */
|
|
598
|
+
export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
|
|
599
|
+
|
|
586
600
|
export async function startDaemon(opts: DaemonOptions) {
|
|
587
601
|
const projectId = opts.projectId ?? randomUUID();
|
|
588
602
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
@@ -620,6 +634,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
620
634
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
621
635
|
const verifiedTerminalRootsThisTurn = new Set<object>();
|
|
622
636
|
const reviewerUnavailable = new Map<string, string>();
|
|
637
|
+
const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
|
|
623
638
|
const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
|
|
624
639
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
625
640
|
const startModesFromInstances: PeerStartConfig = {};
|
|
@@ -735,8 +750,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
735
750
|
let policyRoles = structuredClone(config.roles);
|
|
736
751
|
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
737
752
|
let plannerLogged = "";
|
|
753
|
+
const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
|
|
738
754
|
const settlePlanner = () => {
|
|
739
|
-
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
755
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
|
|
740
756
|
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
741
757
|
Object.assign(config.roles, roles);
|
|
742
758
|
plannerSource = source;
|
|
@@ -744,7 +760,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
744
760
|
if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
|
|
745
761
|
};
|
|
746
762
|
const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
|
|
747
|
-
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`);
|
|
748
763
|
settlePlanner();
|
|
749
764
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
750
765
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
@@ -1053,7 +1068,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1053
1068
|
}, delayMs);
|
|
1054
1069
|
recoveryLeaseTimer.unref?.();
|
|
1055
1070
|
};
|
|
1056
|
-
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
1057
1071
|
const chain = projectChain(opts.cwd);
|
|
1058
1072
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
1059
1073
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
@@ -1097,18 +1111,137 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1097
1111
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
1098
1112
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
1099
1113
|
*/
|
|
1100
|
-
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
1114
|
+
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 ?? []);
|
|
1101
1115
|
let candidates: CandidateStore | undefined;
|
|
1102
1116
|
if (memoryStore) {
|
|
1103
1117
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
1104
1118
|
catch { log("memory candidate sidecar unavailable"); }
|
|
1105
1119
|
}
|
|
1120
|
+
let captureBinding = memoryBinding;
|
|
1121
|
+
if (config.memory.enabled && !captureBinding && !memoryProblem) {
|
|
1122
|
+
try { captureBinding = new MemoryProfileBinding({ project: memoryProject, projectKey: memoryProjectRaw }); }
|
|
1123
|
+
catch { memoryUnavailable("memory profile unavailable; capture disabled"); }
|
|
1124
|
+
}
|
|
1125
|
+
const memory = opts.memoryClient ?? new MemoryClient({
|
|
1126
|
+
project: memoryProject, store: memoryStore, candidates,
|
|
1127
|
+
check: () => { if (memoryProblem || !captureBinding) throw new Error("memory unavailable"); captureBinding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) throw new Error("memory project changed"); },
|
|
1128
|
+
screen: rows => screenMemory(rows),
|
|
1129
|
+
publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
|
|
1130
|
+
capture: () => ({ memoryDir: captureBinding!.home }), log,
|
|
1131
|
+
});
|
|
1106
1132
|
const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
|
|
1107
1133
|
const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
|
|
1108
1134
|
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)}`;
|
|
1109
1135
|
/** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
|
|
1110
1136
|
const taskNodes = (t: { id?: number; created?: number; title?: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }) =>
|
|
1111
|
-
|
|
1137
|
+
taskMemoryQueryNodes({ ...t, title: t.title ?? "" }, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny });
|
|
1138
|
+
|
|
1139
|
+
const memoryCodeCache = new Map<string, { code: import("./memory-code-overlay.ts").MemoryCodeOverlay; proof: MemoryCodeHead; expires: number }>();
|
|
1140
|
+
const memoryCodeProofs = new Map<string, MemoryCodeHead>();
|
|
1141
|
+
const memoryCodeSeeds = new WeakMap<object, Set<string>>();
|
|
1142
|
+
let memoryCodeNextStart = 0;
|
|
1143
|
+
async function memoryCode(starts: readonly string[], valid: () => boolean = () => true, cacheOnly = false) {
|
|
1144
|
+
if (!memoryStore || !starts.length || !valid()) return unavailableMemoryCodeOverlay();
|
|
1145
|
+
const key = JSON.stringify([memoryRepo, starts]), now = performance.now(), cached = memoryCodeCache.get(key);
|
|
1146
|
+
if (cached && cached.expires > now && cached.proof.current() === cached.code.head) return cached.code;
|
|
1147
|
+
if (cacheOnly || now < memoryCodeNextStart) return unavailableMemoryCodeOverlay();
|
|
1148
|
+
memoryCodeNextStart = now + 5000;
|
|
1149
|
+
const proof = readMemoryCodeHead(opts.cwd); if (!proof) return unavailableMemoryCodeOverlay();
|
|
1150
|
+
const head = proof.head; memoryCodeProofs.set(head, proof);
|
|
1151
|
+
if (memoryCodeProofs.size > 32) memoryCodeProofs.delete(memoryCodeProofs.keys().next().value!);
|
|
1152
|
+
const code = await deriveMemoryCodeOverlay(starts, {
|
|
1153
|
+
context: () => ({ cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny }),
|
|
1154
|
+
head: () => head,
|
|
1155
|
+
valid: () => { checkMemory(); return !stopping && valid() && proof.current() === head; },
|
|
1156
|
+
publicMetadataText: text => tasks.publicMetadataText(text) && boundedSafe(text),
|
|
1157
|
+
neighbours: (root, head, selector, depth) => codeFacts.neighbours(root, head, selector, depth, { priority: "optional" }),
|
|
1158
|
+
related: (nodes, limit) => screenMemory(memoryStore!.related(memoryProject, [...nodes], limit)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })),
|
|
1159
|
+
});
|
|
1160
|
+
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)));
|
|
1161
|
+
memoryCodeCache.set(key, { code, proof, expires: performance.now() + (code.state === "known" ? 30_000 : 5000) });
|
|
1162
|
+
if (memoryCodeCache.size > 32) memoryCodeCache.delete(memoryCodeCache.keys().next().value!);
|
|
1163
|
+
return code;
|
|
1164
|
+
}
|
|
1165
|
+
function currentMemoryCode(code: import("./memory-code-overlay.ts").MemoryCodeOverlay) {
|
|
1166
|
+
try {
|
|
1167
|
+
checkMemory();
|
|
1168
|
+
if (!code.head || memoryCodeProofs.get(code.head)?.current() !== code.head || !tasks.publicMetadataText(JSON.stringify({ ...code, notes: [] })) || !boundedSafe(JSON.stringify({ ...code, notes: [] }))) return unavailableMemoryCodeOverlay();
|
|
1169
|
+
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: (text: string) => tasks.publicMetadataText(text) && boundedSafe(text) };
|
|
1170
|
+
if (code.nodes.some(node => canonicalMemorySymbolNode(node.id, context) !== node.id)) return unavailableMemoryCodeOverlay();
|
|
1171
|
+
for (const note of code.notes) {
|
|
1172
|
+
const row = memoryStore?.get(note.id.slice(4), memoryProject);
|
|
1173
|
+
if (!row || `mem:${row.id}` !== note.id || !screenMemory([row]).length || !boundedSafe(JSON.stringify(row))) return unavailableMemoryCodeOverlay();
|
|
1174
|
+
}
|
|
1175
|
+
const seeds = memoryCodeSeeds.get(code) ?? new Set<string>();
|
|
1176
|
+
const neighbours = code.nodes.map(node => node.id).filter(id => !seeds.has(id));
|
|
1177
|
+
const rows = memoryStore && neighbours.length ? screenMemory(memoryStore.related(memoryProject, neighbours, 128)) : [];
|
|
1178
|
+
const notes = rows.filter(row => row.about.some(id => neighbours.includes(id)) && boundedSafe(JSON.stringify(row)))
|
|
1179
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || b.at.localeCompare(a.at)).slice(0, 16)
|
|
1180
|
+
.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 }));
|
|
1181
|
+
return { ...code, notes };
|
|
1182
|
+
} catch { return unavailableMemoryCodeOverlay(); }
|
|
1183
|
+
}
|
|
1184
|
+
async function taskMemoryResult(task: import("../memory/brief.ts").BriefTask, cacheOnly = false) {
|
|
1185
|
+
const maximum = config.memory.brief_items;
|
|
1186
|
+
const current = () => task.id === undefined ? undefined : board.get(task.id), initial = current();
|
|
1187
|
+
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 ?? ""); };
|
|
1188
|
+
if (!memoryStore || initial && tasks.isPii(initial)) return { items: [], state: "unknown" as const };
|
|
1189
|
+
const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
|
|
1190
|
+
let code = unavailableMemoryCodeOverlay();
|
|
1191
|
+
try { if (valid() && starts.length) { const prepared = await memoryCode(starts, valid, cacheOnly); if (valid()) code = currentMemoryCode(prepared); } }
|
|
1192
|
+
catch { /* Optional code failure must not discard existing direct memory advice. */ }
|
|
1193
|
+
checkMemory();
|
|
1194
|
+
const latest = current(); if (latest && tasks.isPii(latest)) return { items: [], state: "unknown" as const };
|
|
1195
|
+
// Optional code evidence never suppresses the existing direct path/title/symbol memory brief.
|
|
1196
|
+
const direct = screenMemory(memoryStore.related(memoryProject, taskNodes(task), 128)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
|
|
1197
|
+
const items = [...new Map([...direct, ...code.notes].map(row => [row.id, row])).values()]
|
|
1198
|
+
.filter(row => [row.line, ...(row.about ?? [])].every(tasks.publicMetadataText))
|
|
1199
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || Number(b.kind === "decision") - Number(a.kind === "decision"))
|
|
1200
|
+
.slice(0, maximum);
|
|
1201
|
+
return { items, state: starts.length ? code.state : "known" as const, ...(code.head ? { head: code.head } : {}) };
|
|
1202
|
+
}
|
|
1203
|
+
async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
|
|
1204
|
+
|
|
1205
|
+
async function taskIntentBrief(task: import("../memory/brief.ts").BriefTask): Promise<import("../memory/brief.ts").IntentBriefContext> {
|
|
1206
|
+
checkMemory();
|
|
1207
|
+
const initial = task.id === undefined ? undefined : board.get(task.id), revision = configRevision;
|
|
1208
|
+
const allowed = () => {
|
|
1209
|
+
const current = task.id === undefined ? undefined : board.get(task.id);
|
|
1210
|
+
return !stopping && configRevision === revision && !!initial && !!current && current.created === task.created && current.updated === initial.updated
|
|
1211
|
+
&& current.history.length === initial.history.length && current.owner === initial.owner && !tasks.isPii(current)
|
|
1212
|
+
&& !turns.get(current.owner ?? "")?.private && !contextPii(current.owner ?? "");
|
|
1213
|
+
};
|
|
1214
|
+
const assertCurrent = () => { checkMemory(); if (!allowed()) throw Error("memory brief context changed"); };
|
|
1215
|
+
assertCurrent();
|
|
1216
|
+
// Derived code neighbours are additional read-time selectors, never persisted memory or authority.
|
|
1217
|
+
const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
|
|
1218
|
+
let extra: string[] = [];
|
|
1219
|
+
if (starts.length && tasks.memoryCodeEligible(initial!)) {
|
|
1220
|
+
try {
|
|
1221
|
+
const prepared = await memoryCode(starts, allowed);
|
|
1222
|
+
assertCurrent();
|
|
1223
|
+
extra = currentMemoryCode(prepared).nodes.map(node => node.id).filter(node => node.startsWith("symbol:")).map(node => node.slice(7));
|
|
1224
|
+
} catch { /* Optional code evidence never removes direct intent memory. */ }
|
|
1225
|
+
}
|
|
1226
|
+
assertCurrent();
|
|
1227
|
+
const stale = staleness();
|
|
1228
|
+
const policy = createStandalonePolicy({ denyPaths: config.local.deny, host: { screen: async value => {
|
|
1229
|
+
assertCurrent();
|
|
1230
|
+
if (value && typeof value === "object" && "id" in value) {
|
|
1231
|
+
const row = value as MemoryView;
|
|
1232
|
+
return screenMemory([row]).length === 1 && tasks.publicMetadataText(JSON.stringify(row));
|
|
1233
|
+
}
|
|
1234
|
+
// The trusted host locator is not note content (a private directory can contain this project).
|
|
1235
|
+
const { cwd: _cwd, ...query } = value as Record<string, unknown>;
|
|
1236
|
+
return boundedSafe(JSON.stringify(query));
|
|
1237
|
+
} } });
|
|
1238
|
+
return { source: createIntentBriefSource(memoryStore!, memoryProject), options: {
|
|
1239
|
+
project: memoryProject, cwd: opts.cwd, repo: memoryRepo, intent: task.title,
|
|
1240
|
+
paths: [...task.refs.paths ?? [], ...task.plan?.paths ?? []], symbols: [...task.plan?.symbols ?? [], ...extra],
|
|
1241
|
+
policy: () => policy, policyVersion: () => String(configRevision), allowed, assertCurrent,
|
|
1242
|
+
stale: row => stale(row.about), now: () => memoryStore!.readNow(),
|
|
1243
|
+
} };
|
|
1244
|
+
}
|
|
1112
1245
|
|
|
1113
1246
|
const leaseNoticed = new Set<string>();
|
|
1114
1247
|
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(", ")}` : ""})` : ""}`;
|
|
@@ -1169,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1169
1302
|
try {
|
|
1170
1303
|
checkMemory();
|
|
1171
1304
|
const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
|
|
1172
|
-
if (!r.ok &&
|
|
1305
|
+
if (!r.ok && !quietSyncError(r.error)) log(`hub memory sync failed: ${r.error}`);
|
|
1173
1306
|
else if (r.warning) log(`hub memory sync: ${r.warning}`);
|
|
1174
1307
|
} catch { /* A stale binding disables native memory, never the bus. */ }
|
|
1175
1308
|
finally { syncRun = undefined; }
|
|
@@ -1180,7 +1313,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1180
1313
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1181
1314
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1182
1315
|
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
|
|
1183
|
-
// The hub's own long-term decisions, contracts and fails lead;
|
|
1316
|
+
// The hub's own long-term decisions, contracts and fails lead; amem's curated short-term notes follow, each with half the budget.
|
|
1184
1317
|
let own: string | undefined;
|
|
1185
1318
|
try {
|
|
1186
1319
|
checkMemory();
|
|
@@ -1247,6 +1380,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1247
1380
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1248
1381
|
const codeFacts = new CodeFacts();
|
|
1249
1382
|
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1383
|
+
const memorySymbols = new Map<string, MemorySymbolNodes>();
|
|
1250
1384
|
startupCleanup.push(() => board.close());
|
|
1251
1385
|
startupCleanup.push(() => codeFacts.close());
|
|
1252
1386
|
const supervision = new SupervisionFeed({
|
|
@@ -1409,7 +1543,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1409
1543
|
};
|
|
1410
1544
|
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1411
1545
|
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1412
|
-
|
|
1546
|
+
// #792: only a reviewer backend runs bound reviews; an unavailable placeholder slot (agy) fences nothing.
|
|
1547
|
+
if (!definitionOf(by)?.reviewer?.backend) return undefined;
|
|
1413
1548
|
const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
|
|
1414
1549
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1415
1550
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
@@ -1460,8 +1595,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1460
1595
|
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1461
1596
|
if ("unavailable" in brief) return;
|
|
1462
1597
|
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1463
|
-
|
|
1464
|
-
if (
|
|
1598
|
+
// prepareOnly and completion share one exact-tree pass, including an unknown result.
|
|
1599
|
+
if (memoryStore && memorySymbols.has(key)) return;
|
|
1600
|
+
const observed = await codeFacts.changedSymbols(root, brief.mergeBase, brief.head);
|
|
1601
|
+
reviewSymbols.set(key, observed);
|
|
1602
|
+
const candidates = tasks.memorySymbolCandidates(task);
|
|
1603
|
+
if (memoryStore && candidates && !tasks.isPii(board.get(task.id) ?? task)) {
|
|
1604
|
+
const nodes = await resolveMemorySymbolNodes(candidates,
|
|
1605
|
+
{ cwd: root, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText },
|
|
1606
|
+
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); });
|
|
1607
|
+
memorySymbols.set(key, nodes);
|
|
1608
|
+
}
|
|
1609
|
+
if (reviewSymbols.size > 64) { const oldest = reviewSymbols.keys().next().value!; reviewSymbols.delete(oldest); memorySymbols.delete(oldest); }
|
|
1610
|
+
},
|
|
1611
|
+
memorySymbolNodes: task => {
|
|
1612
|
+
if (tasks.isPii(task) || !task.codeSymbols || task.codeSymbols.head !== task.refs.commit) return undefined;
|
|
1613
|
+
const root = executionRoot(task.owner ?? "");
|
|
1614
|
+
return memorySymbols.get(JSON.stringify([root, task.codeSymbols.head, task.codeSymbols.mergeBase]));
|
|
1465
1615
|
},
|
|
1466
1616
|
reviewBrief: task => {
|
|
1467
1617
|
const root = executionRoot(task.owner ?? "");
|
|
@@ -1479,9 +1629,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1479
1629
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1480
1630
|
memoryCheck: checkMemory,
|
|
1481
1631
|
...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
|
|
1482
|
-
memoryStore ?
|
|
1483
|
-
(peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
|
|
1484
|
-
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }
|
|
1632
|
+
memoryStore ? taskMemoryItems : undefined,
|
|
1633
|
+
(peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
|
|
1634
|
+
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
|
|
1635
|
+
memoryStore ? taskIntentBrief : undefined) } : {}),
|
|
1485
1636
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
1486
1637
|
checkMemory();
|
|
1487
1638
|
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
|
|
@@ -1915,11 +2066,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1915
2066
|
cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
|
|
1916
2067
|
...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
|
|
1917
2068
|
},
|
|
1918
|
-
report: (peer, window) => budget.report(peer, [{ id: "tokens", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
|
|
1919
|
-
forget: peer => budget.forget(peer, "tokens"),
|
|
2069
|
+
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}` }]),
|
|
2070
|
+
forget: peer => budget.forget(peer, "tokens", "count"),
|
|
1920
2071
|
notice: line => notify(line),
|
|
1921
2072
|
});
|
|
1922
2073
|
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))));
|
|
2074
|
+
/** 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. */
|
|
2075
|
+
const budgetPeerNotes = (): BudgetPeerNote[] => {
|
|
2076
|
+
const quota = budget.status();
|
|
2077
|
+
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);
|
|
2078
|
+
return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
|
|
2079
|
+
hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
|
|
2080
|
+
};
|
|
1923
2081
|
/** 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. */
|
|
1924
2082
|
const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
|
|
1925
2083
|
const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
@@ -2031,7 +2189,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2031
2189
|
const bound = executionOwners.get(peer);
|
|
2032
2190
|
if (bound?.socket) {
|
|
2033
2191
|
const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
2034
|
-
if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2192
|
+
if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
|
|
2035
2193
|
revokeTerminalExecution(peer);
|
|
2036
2194
|
return undefined;
|
|
2037
2195
|
}
|
|
@@ -2145,7 +2303,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2145
2303
|
}
|
|
2146
2304
|
/** Tokens a peer used, as increments. */
|
|
2147
2305
|
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
2306
|
+
// #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.
|
|
2307
|
+
const hubTokenWindows = new Map<PeerId, TokenWindow>();
|
|
2308
|
+
const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
|
|
2309
|
+
const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
|
|
2310
|
+
// 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.
|
|
2311
|
+
const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
|
|
2312
|
+
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 });
|
|
2313
|
+
for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
|
|
2148
2314
|
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
2315
|
+
if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
|
|
2149
2316
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
2150
2317
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
2151
2318
|
if (n > 0) {
|
|
@@ -2304,6 +2471,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2304
2471
|
if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
|
|
2305
2472
|
const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
|
|
2306
2473
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
2474
|
+
const reviewTool = op === "hub_review" || op === "hub_task_show" ? op : undefined;
|
|
2475
|
+
const traceId = admittedId ?? (typeof input?.id === "string" && /^[1-9][0-9]{0,15}$/.test(input.id) && Number.isSafeInteger(Number(input.id)) ? Number(input.id) : undefined);
|
|
2476
|
+
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool);
|
|
2307
2477
|
taskOpsInFlight++;
|
|
2308
2478
|
try {
|
|
2309
2479
|
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
@@ -2319,6 +2489,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2319
2489
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
2320
2490
|
return hint ? `${text}\n\n${hint}` : text;
|
|
2321
2491
|
} catch (error) {
|
|
2492
|
+
if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
|
|
2322
2493
|
if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
|
|
2323
2494
|
// Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
|
|
2324
2495
|
const hint = args[3] ? ownHeldHint(by) : "";
|
|
@@ -2356,10 +2527,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2356
2527
|
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
2357
2528
|
}
|
|
2358
2529
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2359
|
-
//
|
|
2530
|
+
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
2360
2531
|
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`);
|
|
2361
2532
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2362
|
-
// completed-change notice) and
|
|
2533
|
+
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2363
2534
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
2364
2535
|
const target = board.get(Number(a.id));
|
|
2365
2536
|
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`);
|
|
@@ -2381,9 +2552,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2381
2552
|
if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
|
|
2382
2553
|
}
|
|
2383
2554
|
if (MEMORY_READ_TOOLS.has(op)) {
|
|
2555
|
+
validateMemoryRead(op, a);
|
|
2384
2556
|
need("memory_read", "read shared memory");
|
|
2557
|
+
if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2385
2558
|
checkMemory();
|
|
2386
|
-
|
|
2559
|
+
const generation = connectionGeneration(by);
|
|
2560
|
+
const valid = () => may(by, "memory_read") && connectionGeneration(by) === generation && !turns.get(by)?.private && !contextPii(by);
|
|
2561
|
+
const symbolStarts = op === "hub_memory_graph" ? (a.starts as string[]).filter(node => node.startsWith("symbol:")) : [];
|
|
2562
|
+
const code = symbolStarts.length ? await memoryCode(symbolStarts, valid).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
2563
|
+
if (!valid()) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2564
|
+
checkMemory();
|
|
2565
|
+
return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
|
|
2387
2566
|
}
|
|
2388
2567
|
if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
|
|
2389
2568
|
if (op === "hub_remember") need("remember", "save notes to shared memory");
|
|
@@ -2568,7 +2747,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2568
2747
|
chmodSync(`${file}.tmp`, 0o600);
|
|
2569
2748
|
renameSync(`${file}.tmp`, file);
|
|
2570
2749
|
pending.done(summary);
|
|
2571
|
-
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project:
|
|
2750
|
+
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: memoryProject, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
|
|
2572
2751
|
notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
|
|
2573
2752
|
return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
|
|
2574
2753
|
}
|
|
@@ -2630,13 +2809,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2630
2809
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2631
2810
|
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2632
2811
|
const consoleDeliverables = await tasks.deliverableStatus(task);
|
|
2633
|
-
|
|
2812
|
+
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
2813
|
+
const fresh = board.get(task.id);
|
|
2814
|
+
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" };
|
|
2815
|
+
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);
|
|
2816
|
+
}
|
|
2817
|
+
case "deliverables": {
|
|
2818
|
+
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
2819
|
+
if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
|
|
2820
|
+
return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true }));
|
|
2634
2821
|
}
|
|
2635
2822
|
case "task_assign":
|
|
2636
2823
|
case "task_assign_checked": {
|
|
2637
2824
|
const peer = peerArg(a.peer, "peer");
|
|
2638
2825
|
if (!peer) throw new Error("peer is required");
|
|
2639
|
-
if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer") throw new Error("role must be owner or
|
|
2826
|
+
if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer" && a.role !== "planner") throw new Error("role must be owner, reviewer or planner");
|
|
2640
2827
|
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");
|
|
2641
2828
|
return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
|
|
2642
2829
|
}
|
|
@@ -2658,7 +2845,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2658
2845
|
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
2659
2846
|
const turn = turnLog?.get(String(a.turn));
|
|
2660
2847
|
if (!turn) throw new Error(`no turn ${a.turn} recorded`);
|
|
2661
|
-
if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2848
|
+
if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
2662
2849
|
if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
2663
2850
|
const codex = bus.peers.get(turn.peer);
|
|
2664
2851
|
if (!isCodexPeer(codex)) throw new Error("codex is not attached");
|
|
@@ -2714,7 +2901,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2714
2901
|
else delete defaultSources[peer];
|
|
2715
2902
|
}
|
|
2716
2903
|
};
|
|
2717
|
-
|
|
2904
|
+
// Action allowances have their own host session port; adding local here must not change checkpoints, context telemetry or supervision.
|
|
2905
|
+
const actionSession = (peer: PeerId): string | undefined => {
|
|
2906
|
+
const owner = bus.peers.get(peer);
|
|
2907
|
+
if (isLocalPeer(owner)) { const id = owner.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
2908
|
+
return contextSession(peer);
|
|
2909
|
+
};
|
|
2910
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
|
|
2718
2911
|
// #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.
|
|
2719
2912
|
const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
|
|
2720
2913
|
task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
|
|
@@ -2733,11 +2926,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2733
2926
|
return { enforced: true };
|
|
2734
2927
|
}
|
|
2735
2928
|
const owner = bus.peers.get(peer);
|
|
2929
|
+
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
2930
|
+
const generation = owner?.sessionGeneration;
|
|
2931
|
+
return owner?.state === "busy" && typeof generation === "string" && generation.length > 0 ? { enforced: true } : { enforced: false, reason: "no current managed Claude implementer child" };
|
|
2932
|
+
}
|
|
2933
|
+
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" };
|
|
2934
|
+
if (isLocalPeer(owner)) return owner.state !== "offline" ? { enforced: true } : { enforced: false, reason: "the local worker is offline" };
|
|
2935
|
+
if (isPiPeer(owner)) return owner.state !== "offline" && owner.acceptingTools ? { enforced: true } : { enforced: false, reason: "Pi has no verified live tool bridge" };
|
|
2736
2936
|
if (isAcpPeer(owner) && owner.actionGated) {
|
|
2737
2937
|
const mode = owner.permissionModeState;
|
|
2738
2938
|
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` };
|
|
2739
2939
|
}
|
|
2740
|
-
return { enforced: false };
|
|
2940
|
+
return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
|
|
2741
2941
|
};
|
|
2742
2942
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2743
2943
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
@@ -3018,6 +3218,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3018
3218
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
3019
3219
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
3020
3220
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
3221
|
+
memoryRead: memory.readStatus(),
|
|
3021
3222
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
3022
3223
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
3023
3224
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
@@ -3041,7 +3242,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3041
3242
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
3042
3243
|
codexProxyPort: opts.codexProxyPort,
|
|
3043
3244
|
peers: Object.fromEntries(
|
|
3044
|
-
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 ?? [] } : {}) }]; }),
|
|
3245
|
+
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), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(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 ?? [] } : {}) }]; }),
|
|
3045
3246
|
),
|
|
3046
3247
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
3047
3248
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
@@ -3116,7 +3317,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3116
3317
|
settlingSuperseded = true;
|
|
3117
3318
|
try {
|
|
3118
3319
|
for (const row of bus.queueList(undefined, true)) {
|
|
3119
|
-
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
|
|
3320
|
+
if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:") || row.reason?.startsWith(REVIEWER_FALLBACK_UNCONFIRMED)) continue;
|
|
3120
3321
|
const reason = supersededDeliveryReason(row.originals, board, row.peer);
|
|
3121
3322
|
if (!reason) continue;
|
|
3122
3323
|
try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
|
|
@@ -3589,6 +3790,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3589
3790
|
const result = await requestPermission(req, signal);
|
|
3590
3791
|
return result.outcome === "answered" ? result.optionId : undefined;
|
|
3591
3792
|
}
|
|
3793
|
+
/** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
|
|
3794
|
+
async function classedToolPermission(peer: string, root: string, title: string, tool: string | undefined, input: unknown, signal: AbortSignal | undefined, current: () => boolean, observe?: ApprovalObserver): Promise<ToolApproval | undefined> {
|
|
3795
|
+
if (!tool || input === undefined) return undefined;
|
|
3796
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3797
|
+
// runTool executes in this host root and ignores model-supplied cwd/workdir fields.
|
|
3798
|
+
const effective = input && typeof input === "object" && !Array.isArray(input) ? { ...input, cwd: root, workdir: root } : input;
|
|
3799
|
+
let gate: ActionGateVerdict | undefined;
|
|
3800
|
+
try { gate = acpActionGate(peer)({ names: [tool], input: effective }, actionSession(peer) ?? "", title); }
|
|
3801
|
+
catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
|
|
3802
|
+
if (!gate) return undefined;
|
|
3803
|
+
const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
|
|
3804
|
+
if (gate.denyOnly) options.splice(0, 1);
|
|
3805
|
+
if (!gate.denyOnly && gate.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" });
|
|
3806
|
+
const picked = await requestPermission({ peer, title: gate.title, tool, classed: true, options }, signal, observe);
|
|
3807
|
+
if (picked.outcome === "expired") return "expired";
|
|
3808
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3809
|
+
if (picked.outcome !== "answered") return false;
|
|
3810
|
+
if (!gate.denyOnly && picked.optionId === ACTION_BUDGET_OPTION) {
|
|
3811
|
+
try { return gate.budget?.approve() === true; } catch { return false; }
|
|
3812
|
+
}
|
|
3813
|
+
return !gate.denyOnly && picked.optionId === "allow";
|
|
3814
|
+
}
|
|
3592
3815
|
async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
|
|
3593
3816
|
const requestEpoch = permissionAnswerEpoch(req.peer);
|
|
3594
3817
|
const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
|
|
@@ -3813,6 +4036,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3813
4036
|
const owner = bus.peers.get(peer);
|
|
3814
4037
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3815
4038
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
4039
|
+
if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
|
|
3816
4040
|
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" };
|
|
3817
4041
|
if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3818
4042
|
if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
@@ -3912,6 +4136,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3912
4136
|
if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
|
|
3913
4137
|
}
|
|
3914
4138
|
const existing = bus.peers.get(peer);
|
|
4139
|
+
if (adapter?.start.createTui && existing) {
|
|
4140
|
+
// One Qwen at a time, in one form: a headless worker and the person's TUI would both read the same session's work. The TUI is judged by the process it holds, not by peer
|
|
4141
|
+
// state: a detached TUI reads offline while the person's Qwen keeps running, and replacing that adapter would strand it and admit a second Qwen beside it.
|
|
4142
|
+
if (isQwenDualPeer(existing) && existing.holdsTerminal()) return args.mode === "tui" ? { ok: true, already: true } : { ok: false, error: "Qwen runs in its own terminal; end it there before starting a headless one" };
|
|
4143
|
+
if (args.mode === "tui" && !isQwenDualPeer(existing) && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
|
|
4144
|
+
}
|
|
3915
4145
|
if (args.replaceSession) {
|
|
3916
4146
|
if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
|
|
3917
4147
|
if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
|
|
@@ -3959,12 +4189,49 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3959
4189
|
if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
|
|
3960
4190
|
return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
|
|
3961
4191
|
}
|
|
3962
|
-
return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
4192
|
+
return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
|
|
3963
4193
|
}
|
|
3964
4194
|
if (!adapter?.start.reconfigureWhileAttached) {
|
|
3965
4195
|
if (isPiPeer(existing)) await stopPiForReplacement(existing);
|
|
3966
4196
|
else await existing?.stop();
|
|
3967
4197
|
}
|
|
4198
|
+
if (adapter?.start.createTui && args.mode === "tui") {
|
|
4199
|
+
// #286 Part 3: Qwen's own TUI, driven through its dual-output files. The person's terminal runs it; the hub prepares the files and returns the launch.
|
|
4200
|
+
if (declarations[peer]) return { ok: false, error: "declared instances support headless starts only" };
|
|
4201
|
+
if (!onRoster(peer)) return { ok: false, error: "qwen is off roster; a person can add it with ahub roster add qwen" };
|
|
4202
|
+
if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
|
|
4203
|
+
// The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
|
|
4204
|
+
const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
|
|
4205
|
+
if (!verdict.ok) return { ok: false, error: verdict.reason };
|
|
4206
|
+
if (verdict.note) log(verdict.note);
|
|
4207
|
+
if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
|
|
4208
|
+
actionBudgets.revoke(peer);
|
|
4209
|
+
const nativeRoot = nativeStateDir(peer);
|
|
4210
|
+
mkdirSync(nativeRoot, { recursive: true });
|
|
4211
|
+
const qwenState = join(nativeRoot, "qwen");
|
|
4212
|
+
const tui: QwenDualPeer = adapter.start.createTui({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["qwen-dual"]({
|
|
4213
|
+
cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
4214
|
+
mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
|
|
4215
|
+
watchdogMs: config.watchdog_ms, preamble: sessionRoleContract(peer),
|
|
4216
|
+
// The hub's own tools only, by their exact `mcp__agent-hub__<name>`; everything else asks the console. Never a prefix, never a title.
|
|
4217
|
+
autoApprove: name => !stopping && HUB_TOOL_TITLES.has(name),
|
|
4218
|
+
// `stateDir` is the hub's own state dir: the adapter refuses any Qwen write or command that names it (the input file is a prompt channel).
|
|
4219
|
+
guardDir: opts.stateDir,
|
|
4220
|
+
onPermission, actionGate: (call, session) => acpActionGate(peer)({ names: call.names, input: call.input }, session),
|
|
4221
|
+
onStalled: () => writeStatus(),
|
|
4222
|
+
onNotice: text => { try { notify(text); } catch { /* the notice is advisory */ } },
|
|
4223
|
+
onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
|
|
4224
|
+
log,
|
|
4225
|
+
}) }) as QwenDualPeer;
|
|
4226
|
+
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
4227
|
+
executionOwners.set(peer, { owner: tui, cwd: executionCwd });
|
|
4228
|
+
try {
|
|
4229
|
+
await tui.start();
|
|
4230
|
+
bus.add(tui);
|
|
4231
|
+
if (tui.state !== "offline") tui.onState?.(tui.state);
|
|
4232
|
+
} catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
|
|
4233
|
+
return { ok: true, mode: "tui", ...(tui.adopted ? { adopted: true } : { launch: tui.launch }) };
|
|
4234
|
+
}
|
|
3968
4235
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3969
4236
|
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
|
|
3970
4237
|
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
@@ -4080,9 +4347,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4080
4347
|
if (adapter?.start.daemonPort === "codex-appserver") {
|
|
4081
4348
|
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" };
|
|
4082
4349
|
let codexSteering = false;
|
|
4350
|
+
actionBudgets.revoke(peer);
|
|
4083
4351
|
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"]({
|
|
4084
4352
|
admitAttach: () => membershipAllowed(peer),
|
|
4085
4353
|
permissionMode: permissionMode(peer),
|
|
4354
|
+
actionGate: acpActionGate(peer), onPermission,
|
|
4086
4355
|
onTokens: (added) => void addTokens(peer, added),
|
|
4087
4356
|
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
4088
4357
|
// 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).
|
|
@@ -4181,7 +4450,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4181
4450
|
let piReply: Envelope | undefined;
|
|
4182
4451
|
// Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
|
|
4183
4452
|
const piAlways = new Set<string>();
|
|
4184
|
-
|
|
4453
|
+
actionBudgets.revoke(peer);
|
|
4454
|
+
const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4455
|
+
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4456
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
|
|
4457
|
+
if (classed !== undefined) return classed;
|
|
4185
4458
|
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4186
4459
|
const selected = permissionMode(peer);
|
|
4187
4460
|
if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
@@ -4231,8 +4504,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4231
4504
|
const nativeTurn = pi.observationTurn;
|
|
4232
4505
|
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,
|
|
4233
4506
|
...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
|
|
4234
|
-
permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
|
|
4235
|
-
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
|
|
4507
|
+
permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
|
|
4508
|
+
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
|
|
4236
4509
|
return picked; // never turn a nonempty expiry/abort string into authorization
|
|
4237
4510
|
},
|
|
4238
4511
|
});
|
|
@@ -4342,9 +4615,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4342
4615
|
if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
4343
4616
|
if (existing) { mute(existing); await existing.stop(); }
|
|
4344
4617
|
const capture = config.memory.enabled
|
|
4345
|
-
? new Capture(memory, { project:
|
|
4618
|
+
? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
|
|
4346
4619
|
: undefined;
|
|
4347
|
-
|
|
4620
|
+
actionBudgets.revoke(peer);
|
|
4621
|
+
const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4622
|
+
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4623
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
|
|
4624
|
+
if (classed !== undefined) return classed;
|
|
4348
4625
|
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4349
4626
|
const selected = permissionMode(peer);
|
|
4350
4627
|
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
@@ -4457,7 +4734,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4457
4734
|
privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
|
|
4458
4735
|
bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
|
|
4459
4736
|
taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
|
|
4460
|
-
call: (name, args) => taskOp(peer, name, args, true, contextPii(peer))
|
|
4737
|
+
call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)),
|
|
4738
|
+
onTool: (tool, refusal) => { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); } }),
|
|
4461
4739
|
admit: env => {
|
|
4462
4740
|
if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
|
|
4463
4741
|
{ head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
|
|
@@ -4471,16 +4749,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4471
4749
|
facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
|
|
4472
4750
|
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
4473
4751
|
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
4752
|
+
onNoVerdict: (diagnostic, current) => {
|
|
4753
|
+
const task = board.get(diagnostic.task);
|
|
4754
|
+
const visible = current() && bus.peers.get(peer) === reviewer && !!task && !tasks.isPii(task) && !contextPii(peer)
|
|
4755
|
+
&& tasks.reviewBindingCurrent(peer, diagnostic.task, diagnostic);
|
|
4756
|
+
// Screen the complete refusal before any sanitization or bound; no task text or model output is logged.
|
|
4757
|
+
const hostSafe = visible && !diagnostic.refusalUncertain && (diagnostic.hostRefusal === undefined || boundedSafe(diagnostic.hostRefusal) && tasks.publicMetadataText(diagnostic.hostRefusal));
|
|
4758
|
+
const safe = visible && hostSafe && diagnostic.refusal !== undefined && boundedSafe(diagnostic.refusal) && tasks.publicMetadataText(diagnostic.refusal) ? diagnostic.refusal : undefined;
|
|
4759
|
+
log(reviewerNoVerdictLine(peer, diagnostic, safe, !visible));
|
|
4760
|
+
},
|
|
4474
4761
|
onUnavailable: (failure, current) => {
|
|
4475
4762
|
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
4476
|
-
if (!attached()) return;
|
|
4763
|
+
if (!attached()) return false;
|
|
4477
4764
|
if (failure.cause === "start_failed") {
|
|
4478
4765
|
// #722: the class (a closed list) is the only detail; never a path, native text or token.
|
|
4479
4766
|
const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
|
|
4480
4767
|
log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
|
|
4481
4768
|
reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
|
|
4482
4769
|
}
|
|
4483
|
-
|
|
4770
|
+
let confirmed = false;
|
|
4771
|
+
return tasks.retryReviewer(failure.task, peer, failure, attached, () => { confirmed = true; }).then(() => confirmed);
|
|
4484
4772
|
},
|
|
4485
4773
|
log,
|
|
4486
4774
|
}) }) as ReviewerPrintPeer;
|
|
@@ -4496,6 +4784,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4496
4784
|
}
|
|
4497
4785
|
reviewerUnavailable.delete(peer);
|
|
4498
4786
|
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
4787
|
+
reviewerToolObservers.set(peer, (task, tool, refusal) => {
|
|
4788
|
+
try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
|
|
4789
|
+
catch { /* Diagnostics never change a host tool's result or authority. */ }
|
|
4790
|
+
});
|
|
4499
4791
|
bus.add(reviewer);
|
|
4500
4792
|
return { ok: true };
|
|
4501
4793
|
}
|
|
@@ -4567,7 +4859,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4567
4859
|
const summary = bus.queueSummary(peer);
|
|
4568
4860
|
return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
|
|
4569
4861
|
manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
|
|
4570
|
-
...(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 })) } : {}) };
|
|
4862
|
+
...(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 } : {}) })) } : {}) };
|
|
4571
4863
|
}),
|
|
4572
4864
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
4573
4865
|
};
|
|
@@ -4664,9 +4956,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4664
4956
|
entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
|
|
4665
4957
|
} catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
|
|
4666
4958
|
}
|
|
4959
|
+
async function uiMemorySnapshot(after: number, input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4960
|
+
const query = input?.memory as Record<string, unknown> | undefined;
|
|
4961
|
+
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;
|
|
4962
|
+
const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
4963
|
+
const snapshot = uiSnapshot(after, input, session);
|
|
4964
|
+
if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
|
|
4965
|
+
const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
|
|
4966
|
+
const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
|
|
4967
|
+
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)
|
|
4968
|
+
&& (!query?.since || typeof query.since !== "string" || !note.at || note.at >= query.since)
|
|
4969
|
+
&& (!search || (() => { const row = memoryStore?.get(note.id.slice(4), memoryProject); return !!row && `${row.title ?? ""} ${row.text}`.toLowerCase().includes(search); })()));
|
|
4970
|
+
Object.assign(snapshot.memory, { code: published });
|
|
4971
|
+
}
|
|
4972
|
+
return snapshot;
|
|
4973
|
+
}
|
|
4667
4974
|
function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4668
4975
|
const quota = budget.status();
|
|
4669
4976
|
const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
|
|
4977
|
+
const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
|
|
4670
4978
|
// #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
|
|
4671
4979
|
// so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
|
|
4672
4980
|
const turnView = (peer: PeerId) => {
|
|
@@ -4744,10 +5052,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4744
5052
|
tasks: boardTasks.map((task) => {
|
|
4745
5053
|
const view = tasks.publicView(task);
|
|
4746
5054
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4747
|
-
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 } : {}) };
|
|
5055
|
+
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 } : {}; })() };
|
|
4748
5056
|
}),
|
|
4749
5057
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4750
|
-
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))) }])),
|
|
5058
|
+
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))) }])),
|
|
4751
5059
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
4752
5060
|
const req = JSON.parse(push);
|
|
4753
5061
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -4804,7 +5112,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4804
5112
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4805
5113
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4806
5114
|
const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
|
|
4807
|
-
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
|
|
5115
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled), peer => plannerEligible.has(peer));
|
|
4808
5116
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4809
5117
|
}
|
|
4810
5118
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -5061,7 +5369,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5061
5369
|
},
|
|
5062
5370
|
});
|
|
5063
5371
|
const undo = pendingUndo(opts.stateDir);
|
|
5064
|
-
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it
|
|
5372
|
+
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.`;
|
|
5065
5373
|
return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
5066
5374
|
}
|
|
5067
5375
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
@@ -5477,6 +5785,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5477
5785
|
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";
|
|
5478
5786
|
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";
|
|
5479
5787
|
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";
|
|
5788
|
+
if ((Array.isArray(config.planner) || typeof config.planner === "string" && extraIds.includes(config.planner) || extraIds.some(id => declarations[id]!.roles?.includes("planner")) || board.hasPlannerState())
|
|
5789
|
+
&& (!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";
|
|
5480
5790
|
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";
|
|
5481
5791
|
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";
|
|
5482
5792
|
return undefined;
|
|
@@ -5598,6 +5908,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5598
5908
|
}
|
|
5599
5909
|
|
|
5600
5910
|
function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
5911
|
+
if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
|
|
5601
5912
|
const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
|
|
5602
5913
|
return row?.permissionHook ? row : undefined;
|
|
5603
5914
|
}
|
|
@@ -5612,7 +5923,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5612
5923
|
// A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
|
|
5613
5924
|
// to it would run a mode the status no longer shows.
|
|
5614
5925
|
// It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
|
|
5615
|
-
const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
|
|
5926
|
+
const note = isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
|
|
5616
5927
|
if (note) log(`permission mode codex: ${note}`);
|
|
5617
5928
|
const from = permissionMode(peer);
|
|
5618
5929
|
permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
|
|
@@ -5620,7 +5931,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5620
5931
|
log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
|
|
5621
5932
|
return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
|
|
5622
5933
|
}
|
|
5623
|
-
if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5934
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5624
5935
|
if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
|
|
5625
5936
|
if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
|
|
5626
5937
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
@@ -5629,7 +5940,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5629
5940
|
const launch = claudePermissionLaunch(peer);
|
|
5630
5941
|
if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
5631
5942
|
if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
5632
|
-
} else if (isCodexPeer(owner)) {
|
|
5943
|
+
} else if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
|
|
5633
5944
|
if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
|
|
5634
5945
|
if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
5635
5946
|
}
|
|
@@ -5661,7 +5972,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5661
5972
|
}
|
|
5662
5973
|
const owner = bus.peers.get(peer);
|
|
5663
5974
|
if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
|
|
5664
|
-
if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
5975
|
+
if (!starting.has(peer) && isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
|
|
5665
5976
|
if (!starting.has(peer) && owner && owner.state !== "offline") {
|
|
5666
5977
|
const result = await changePermission(peer, "never-ask", true);
|
|
5667
5978
|
if (!result.ok) return result;
|
|
@@ -5692,9 +6003,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5692
6003
|
const result = await permissionChange(peer, async () => {
|
|
5693
6004
|
if (stopping) return { ok: false, error: "hub is stopping" };
|
|
5694
6005
|
const selected = permissionMode(peer);
|
|
5695
|
-
if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
|
|
6006
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
|
|
5696
6007
|
if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
|
|
5697
|
-
if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
6008
|
+
if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
|
|
5698
6009
|
else await owner.setPermissionMode(selected);
|
|
5699
6010
|
}
|
|
5700
6011
|
// The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
|
|
@@ -5773,7 +6084,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5773
6084
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
5774
6085
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
5775
6086
|
ws.claim(sock, msg.channel === false);
|
|
5776
|
-
if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
6087
|
+
if (declarations[c.peer] && isFixedBuiltinDefinition(definitionOf(c.peer)) && definitionOf(c.peer)?.transport === "claude-channel") {
|
|
5777
6088
|
revokeTerminalExecution(c.peer);
|
|
5778
6089
|
const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
|
|
5779
6090
|
try {
|
|
@@ -6263,7 +6574,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6263
6574
|
case "ui":
|
|
6264
6575
|
if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
|
|
6265
6576
|
try {
|
|
6266
|
-
dashboard ??= startDashboard({ snapshot:
|
|
6577
|
+
dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
|
|
6267
6578
|
writeStatus();
|
|
6268
6579
|
// #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
|
|
6269
6580
|
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");
|
|
@@ -6352,7 +6663,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6352
6663
|
case "ui_snapshot":
|
|
6353
6664
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
6354
6665
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
6355
|
-
|
|
6666
|
+
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" }));
|
|
6667
|
+
return;
|
|
6356
6668
|
case "ui_action":
|
|
6357
6669
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
6358
6670
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -6480,7 +6792,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6480
6792
|
// Screened before the caps: withheld entries never crowd out readable evidence.
|
|
6481
6793
|
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);
|
|
6482
6794
|
const near = screenMemory(memoryStore!.related(memoryProject, [...new Set(hits.flatMap((v) => v.about))], Number.MAX_SAFE_INTEGER)).slice(0, 6);
|
|
6483
|
-
return [...hits, ...near].map((v) => ({ id: `mem:${v.id
|
|
6795
|
+
return [...hits, ...near].map((v) => ({ id: `mem:${v.id}`, text: memoryLine(v).slice(13) }));
|
|
6484
6796
|
} } : {}),
|
|
6485
6797
|
project: chain.at(-1)!,
|
|
6486
6798
|
logFile,
|
|
@@ -6500,7 +6812,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6500
6812
|
project: chain.at(-1)!,
|
|
6501
6813
|
metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
|
|
6502
6814
|
});
|
|
6503
|
-
saved = ok ? "saved
|
|
6815
|
+
saved = ok ? "saved as a candidate model-written answer; a person must review it" : "memory store unavailable; nothing saved";
|
|
6504
6816
|
}
|
|
6505
6817
|
}
|
|
6506
6818
|
reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
|
|
@@ -6510,7 +6822,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6510
6822
|
case "budget":
|
|
6511
6823
|
if (c.role !== "console") {
|
|
6512
6824
|
if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
|
|
6513
|
-
return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), gate: config.budget.gate });
|
|
6825
|
+
return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6514
6826
|
}
|
|
6515
6827
|
if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
|
|
6516
6828
|
if (msg.resume) {
|
|
@@ -6519,9 +6831,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6519
6831
|
const used = Number(msg.set.used);
|
|
6520
6832
|
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}` });
|
|
6521
6833
|
if (!(used >= 0 && used <= 1)) return void reply({ t: "budget", ok: false, error: "used must be between 0 and 1" });
|
|
6522
|
-
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" }]);
|
|
6834
|
+
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" }]);
|
|
6523
6835
|
}
|
|
6524
|
-
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6836
|
+
return void reply({ t: "budget", ok: true, budget: budget.status(), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6525
6837
|
case "usage": {
|
|
6526
6838
|
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6527
6839
|
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
@@ -6626,6 +6938,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6626
6938
|
executionBudget.close();
|
|
6627
6939
|
syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
|
|
6628
6940
|
await syncRun?.catch(() => undefined);
|
|
6941
|
+
memory.close();
|
|
6629
6942
|
candidates?.close();
|
|
6630
6943
|
memoryStore?.close();
|
|
6631
6944
|
board.close();
|