@staix/agent-hub 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -8
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +13 -1
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +13 -4
- package/docs/events.md +1 -1
- package/docs/memory.md +78 -10
- package/docs/operations.md +116 -30
- package/docs/quickstart.md +8 -4
- package/docs/security.md +80 -18
- package/docs/smoke.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
- package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
- package/docs/verification/2026-10-24-amem-runtime.md +69 -0
- package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
- package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
- package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
- package/docs/verification/2026-10-25-0.16.0.md +82 -0
- package/docs/verified.json +26 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2085 -1702
- package/src/adapters/acp.ts +5 -2
- package/src/adapters/codex-appserver.ts +69 -1
- package/src/adapters/compat.ts +2 -2
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/local-worker.ts +5 -4
- package/src/adapters/registry-types.ts +1 -1
- 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/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 +250 -49
- 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 +10 -1
- 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 +353 -64
- 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 +22 -12
- package/src/memory/capture.ts +26 -40
- package/src/memory/client.ts +137 -65
- package/src/memory/recall.ts +13 -22
- package/src/ui/index.html +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";
|
|
@@ -67,11 +70,13 @@ 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";
|
|
77
82
|
import { startModelRelay, type ModelRelay } from "../models/relay.ts";
|
|
@@ -88,7 +93,7 @@ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableErro
|
|
|
88
93
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
89
94
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
90
95
|
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
91
|
-
import { Capture
|
|
96
|
+
import { Capture } from "../memory/capture.ts";
|
|
92
97
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
93
98
|
import { Briefs } from "../memory/brief.ts";
|
|
94
99
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
@@ -120,7 +125,7 @@ import { codeGroup } from "./task-code-group.ts";
|
|
|
120
125
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
121
126
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
122
127
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
123
|
-
import { MemoryClient
|
|
128
|
+
import { MemoryClient } from "../memory/client.ts";
|
|
124
129
|
import { VERSION } from "../version.ts";
|
|
125
130
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
126
131
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
@@ -144,9 +149,9 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
|
|
|
144
149
|
import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
|
|
145
150
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
146
151
|
import { detectTooling } from "./tooling.ts";
|
|
147
|
-
import { looksSecret, DEFAULT_LEASE_MS,
|
|
152
|
+
import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
148
153
|
import { memoryProjectKey } from "@staix/agent-mem";
|
|
149
|
-
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates,
|
|
154
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
150
155
|
import { syncMemory } from "@staix/agent-mem";
|
|
151
156
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
152
157
|
import { nativeRecall } from "../memory/recall.ts";
|
|
@@ -177,7 +182,7 @@ export interface HubConfig {
|
|
|
177
182
|
roster: PeerRoster;
|
|
178
183
|
roles: Record<string, string[]>;
|
|
179
184
|
/** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
|
|
180
|
-
planner?: string;
|
|
185
|
+
planner?: string | string[];
|
|
181
186
|
profile?: ProfileName;
|
|
182
187
|
profile_overrides?: Record<string, unknown>;
|
|
183
188
|
pii?: { paths: string[] };
|
|
@@ -286,11 +291,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
|
|
|
286
291
|
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
287
292
|
|
|
288
293
|
/** 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[] } {
|
|
294
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
290
295
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
291
296
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
292
297
|
const notices: string[] = [];
|
|
293
|
-
let planner: string | undefined;
|
|
298
|
+
let planner: string | string[] | undefined;
|
|
294
299
|
for (const name of CONFIG_FILES) {
|
|
295
300
|
let file: Record<string, unknown>;
|
|
296
301
|
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
@@ -308,8 +313,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
308
313
|
}
|
|
309
314
|
}
|
|
310
315
|
if (file.planner !== undefined) {
|
|
311
|
-
if (
|
|
312
|
-
|
|
316
|
+
if (file.planner === null) throw new Error("planner takes a peer id or a nonempty list of up to eight ids");
|
|
317
|
+
const def = instanceSettingDef("planner", scope.instances, scope.catalog);
|
|
318
|
+
if (typeof def === "string") throw new Error(def);
|
|
319
|
+
const normalized = checkSettingValue(def, file.planner) as string | string[];
|
|
320
|
+
planner = Array.isArray(file.planner) ? [...file.planner] as string[] : normalized;
|
|
313
321
|
}
|
|
314
322
|
if (file.conductor !== undefined) {
|
|
315
323
|
if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
|
|
@@ -418,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
418
426
|
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
419
427
|
}
|
|
420
428
|
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);
|
|
429
|
+
const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
|
|
422
430
|
retired.push(...conductorPolicy.notices ?? []);
|
|
423
431
|
const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
424
432
|
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
|
|
@@ -501,6 +509,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
501
509
|
}
|
|
502
510
|
|
|
503
511
|
export interface DaemonOptions {
|
|
512
|
+
/** Host-only test/embedding port; never read from project configuration or a tool. */
|
|
513
|
+
memoryClient?: MemoryClient;
|
|
504
514
|
/** Host-only snapshot/probe seams; never read from project config or tool arguments. */
|
|
505
515
|
snapshot?: typeof snapshot;
|
|
506
516
|
snapshotBurstMs?: number;
|
|
@@ -735,8 +745,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
735
745
|
let policyRoles = structuredClone(config.roles);
|
|
736
746
|
let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
|
|
737
747
|
let plannerLogged = "";
|
|
748
|
+
const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
|
|
738
749
|
const settlePlanner = () => {
|
|
739
|
-
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
|
|
750
|
+
const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
|
|
740
751
|
for (const key of Object.keys(config.roles)) delete config.roles[key];
|
|
741
752
|
Object.assign(config.roles, roles);
|
|
742
753
|
plannerSource = source;
|
|
@@ -744,7 +755,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
744
755
|
if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
|
|
745
756
|
};
|
|
746
757
|
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
758
|
settlePlanner();
|
|
749
759
|
const catalog = loadRoleCatalog(opts.cwd);
|
|
750
760
|
for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
|
|
@@ -1053,7 +1063,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1053
1063
|
}, delayMs);
|
|
1054
1064
|
recoveryLeaseTimer.unref?.();
|
|
1055
1065
|
};
|
|
1056
|
-
const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
|
|
1057
1066
|
const chain = projectChain(opts.cwd);
|
|
1058
1067
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
1059
1068
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
@@ -1097,18 +1106,96 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1097
1106
|
* Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
|
|
1098
1107
|
* policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
|
|
1099
1108
|
*/
|
|
1100
|
-
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
1109
|
+
const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs.filter(v => !looksSecret(`${v.title ?? ""}\n${v.text}`) && !v.title?.startsWith(ASK_NOTE_TITLE)), activeRouting(), opts.cwd, config.pii?.paths ?? []);
|
|
1101
1110
|
let candidates: CandidateStore | undefined;
|
|
1102
1111
|
if (memoryStore) {
|
|
1103
1112
|
try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
|
|
1104
1113
|
catch { log("memory candidate sidecar unavailable"); }
|
|
1105
1114
|
}
|
|
1115
|
+
let captureBinding = memoryBinding;
|
|
1116
|
+
if (config.memory.enabled && !captureBinding && !memoryProblem) {
|
|
1117
|
+
try { captureBinding = new MemoryProfileBinding({ project: memoryProject, projectKey: memoryProjectRaw }); }
|
|
1118
|
+
catch { memoryUnavailable("memory profile unavailable; capture disabled"); }
|
|
1119
|
+
}
|
|
1120
|
+
const memory = opts.memoryClient ?? new MemoryClient({
|
|
1121
|
+
project: memoryProject, store: memoryStore, candidates,
|
|
1122
|
+
check: () => { if (memoryProblem || !captureBinding) throw new Error("memory unavailable"); captureBinding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) throw new Error("memory project changed"); },
|
|
1123
|
+
screen: rows => screenMemory(rows),
|
|
1124
|
+
publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
|
|
1125
|
+
capture: () => ({ memoryDir: captureBinding!.home }), log,
|
|
1126
|
+
});
|
|
1106
1127
|
const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
|
|
1107
1128
|
const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
|
|
1108
1129
|
const memoryLine = (v: MemoryView) => `mem:${v.id.slice(0, 8)} ${v.kind} ${v.tier}: ${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 160)}`;
|
|
1109
1130
|
/** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
|
|
1110
1131
|
const taskNodes = (t: { id?: number; created?: number; title?: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }) =>
|
|
1111
|
-
|
|
1132
|
+
taskMemoryQueryNodes({ ...t, title: t.title ?? "" }, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny });
|
|
1133
|
+
|
|
1134
|
+
const memoryCodeCache = new Map<string, { code: import("./memory-code-overlay.ts").MemoryCodeOverlay; proof: MemoryCodeHead; expires: number }>();
|
|
1135
|
+
const memoryCodeProofs = new Map<string, MemoryCodeHead>();
|
|
1136
|
+
const memoryCodeSeeds = new WeakMap<object, Set<string>>();
|
|
1137
|
+
let memoryCodeNextStart = 0;
|
|
1138
|
+
async function memoryCode(starts: readonly string[], valid: () => boolean = () => true, cacheOnly = false) {
|
|
1139
|
+
if (!memoryStore || !starts.length || !valid()) return unavailableMemoryCodeOverlay();
|
|
1140
|
+
const key = JSON.stringify([memoryRepo, starts]), now = performance.now(), cached = memoryCodeCache.get(key);
|
|
1141
|
+
if (cached && cached.expires > now && cached.proof.current() === cached.code.head) return cached.code;
|
|
1142
|
+
if (cacheOnly || now < memoryCodeNextStart) return unavailableMemoryCodeOverlay();
|
|
1143
|
+
memoryCodeNextStart = now + 5000;
|
|
1144
|
+
const proof = readMemoryCodeHead(opts.cwd); if (!proof) return unavailableMemoryCodeOverlay();
|
|
1145
|
+
const head = proof.head; memoryCodeProofs.set(head, proof);
|
|
1146
|
+
if (memoryCodeProofs.size > 32) memoryCodeProofs.delete(memoryCodeProofs.keys().next().value!);
|
|
1147
|
+
const code = await deriveMemoryCodeOverlay(starts, {
|
|
1148
|
+
context: () => ({ cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny }),
|
|
1149
|
+
head: () => head,
|
|
1150
|
+
valid: () => { checkMemory(); return !stopping && valid() && proof.current() === head; },
|
|
1151
|
+
publicMetadataText: text => tasks.publicMetadataText(text) && boundedSafe(text),
|
|
1152
|
+
neighbours: (root, head, selector, depth) => codeFacts.neighbours(root, head, selector, depth, { priority: "optional" }),
|
|
1153
|
+
related: (nodes, limit) => screenMemory(memoryStore!.related(memoryProject, [...nodes], limit)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })),
|
|
1154
|
+
});
|
|
1155
|
+
memoryCodeSeeds.set(code, new Set(starts.map(raw => canonicalMemorySymbolNode(raw, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText })).filter((id): id is string => !!id)));
|
|
1156
|
+
memoryCodeCache.set(key, { code, proof, expires: performance.now() + (code.state === "known" ? 30_000 : 5000) });
|
|
1157
|
+
if (memoryCodeCache.size > 32) memoryCodeCache.delete(memoryCodeCache.keys().next().value!);
|
|
1158
|
+
return code;
|
|
1159
|
+
}
|
|
1160
|
+
function currentMemoryCode(code: import("./memory-code-overlay.ts").MemoryCodeOverlay) {
|
|
1161
|
+
try {
|
|
1162
|
+
checkMemory();
|
|
1163
|
+
if (!code.head || memoryCodeProofs.get(code.head)?.current() !== code.head || !tasks.publicMetadataText(JSON.stringify({ ...code, notes: [] })) || !boundedSafe(JSON.stringify({ ...code, notes: [] }))) return unavailableMemoryCodeOverlay();
|
|
1164
|
+
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: (text: string) => tasks.publicMetadataText(text) && boundedSafe(text) };
|
|
1165
|
+
if (code.nodes.some(node => canonicalMemorySymbolNode(node.id, context) !== node.id)) return unavailableMemoryCodeOverlay();
|
|
1166
|
+
for (const note of code.notes) {
|
|
1167
|
+
const row = memoryStore?.get(note.id.slice(4), memoryProject);
|
|
1168
|
+
if (!row || `mem:${row.id}` !== note.id || !screenMemory([row]).length || !boundedSafe(JSON.stringify(row))) return unavailableMemoryCodeOverlay();
|
|
1169
|
+
}
|
|
1170
|
+
const seeds = memoryCodeSeeds.get(code) ?? new Set<string>();
|
|
1171
|
+
const neighbours = code.nodes.map(node => node.id).filter(id => !seeds.has(id));
|
|
1172
|
+
const rows = memoryStore && neighbours.length ? screenMemory(memoryStore.related(memoryProject, neighbours, 128)) : [];
|
|
1173
|
+
const notes = rows.filter(row => row.about.some(id => neighbours.includes(id)) && boundedSafe(JSON.stringify(row)))
|
|
1174
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || b.at.localeCompare(a.at)).slice(0, 16)
|
|
1175
|
+
.map(row => ({ id: `mem:${row.id}`, line: memoryLine(row), kind: row.kind, about: row.about, tier: row.tier, peer: row.peer, machine: row.machine, at: row.at }));
|
|
1176
|
+
return { ...code, notes };
|
|
1177
|
+
} catch { return unavailableMemoryCodeOverlay(); }
|
|
1178
|
+
}
|
|
1179
|
+
async function taskMemoryResult(task: import("../memory/brief.ts").BriefTask, cacheOnly = false) {
|
|
1180
|
+
const maximum = config.memory.brief_items;
|
|
1181
|
+
const current = () => task.id === undefined ? undefined : board.get(task.id), initial = current();
|
|
1182
|
+
const valid = () => { const value = current(); return !!initial && !!value && value.created === task.created && value.updated === initial.updated && value.history.length === initial.history.length && value.owner === initial.owner && tasks.memoryCodeEligible(value) && !turns.get(value.owner ?? "")?.private && !contextPii(value.owner ?? ""); };
|
|
1183
|
+
if (!memoryStore || initial && tasks.isPii(initial)) return { items: [], state: "unknown" as const };
|
|
1184
|
+
const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
|
|
1185
|
+
let code = unavailableMemoryCodeOverlay();
|
|
1186
|
+
try { if (valid() && starts.length) { const prepared = await memoryCode(starts, valid, cacheOnly); if (valid()) code = currentMemoryCode(prepared); } }
|
|
1187
|
+
catch { /* Optional code failure must not discard existing direct memory advice. */ }
|
|
1188
|
+
checkMemory();
|
|
1189
|
+
const latest = current(); if (latest && tasks.isPii(latest)) return { items: [], state: "unknown" as const };
|
|
1190
|
+
// Optional code evidence never suppresses the existing direct path/title/symbol memory brief.
|
|
1191
|
+
const direct = screenMemory(memoryStore.related(memoryProject, taskNodes(task), 128)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
|
|
1192
|
+
const items = [...new Map([...direct, ...code.notes].map(row => [row.id, row])).values()]
|
|
1193
|
+
.filter(row => [row.line, ...(row.about ?? [])].every(tasks.publicMetadataText))
|
|
1194
|
+
.sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || Number(b.kind === "decision") - Number(a.kind === "decision"))
|
|
1195
|
+
.slice(0, maximum);
|
|
1196
|
+
return { items, state: starts.length ? code.state : "known" as const, ...(code.head ? { head: code.head } : {}) };
|
|
1197
|
+
}
|
|
1198
|
+
async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
|
|
1112
1199
|
|
|
1113
1200
|
const leaseNoticed = new Set<string>();
|
|
1114
1201
|
const goalLine = (g: GoalView) => `goal:${g.id.slice(0, 8)} ${g.state} ${g.title}${g.lease.holder ? ` (held by ${g.lease.holder === memoryStore?.machine.id ? "this machine" : `machine ${g.lease.holder.slice(0, 8)}`} until ${g.lease.until}${g.lease.conflict.length ? `; conflicting claim from ${g.lease.conflict.map((m) => m.slice(0, 8)).join(", ")}` : ""})` : ""}`;
|
|
@@ -1180,7 +1267,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1180
1267
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1181
1268
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1182
1269
|
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;
|
|
1270
|
+
// The hub's own long-term decisions, contracts and fails lead; amem's curated short-term notes follow, each with half the budget.
|
|
1184
1271
|
let own: string | undefined;
|
|
1185
1272
|
try {
|
|
1186
1273
|
checkMemory();
|
|
@@ -1247,6 +1334,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1247
1334
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1248
1335
|
const codeFacts = new CodeFacts();
|
|
1249
1336
|
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1337
|
+
const memorySymbols = new Map<string, MemorySymbolNodes>();
|
|
1250
1338
|
startupCleanup.push(() => board.close());
|
|
1251
1339
|
startupCleanup.push(() => codeFacts.close());
|
|
1252
1340
|
const supervision = new SupervisionFeed({
|
|
@@ -1460,8 +1548,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1460
1548
|
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1461
1549
|
if ("unavailable" in brief) return;
|
|
1462
1550
|
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1463
|
-
|
|
1464
|
-
if (
|
|
1551
|
+
// prepareOnly and completion share one exact-tree pass, including an unknown result.
|
|
1552
|
+
if (memoryStore && memorySymbols.has(key)) return;
|
|
1553
|
+
const observed = await codeFacts.changedSymbols(root, brief.mergeBase, brief.head);
|
|
1554
|
+
reviewSymbols.set(key, observed);
|
|
1555
|
+
const candidates = tasks.memorySymbolCandidates(task);
|
|
1556
|
+
if (memoryStore && candidates && !tasks.isPii(board.get(task.id) ?? task)) {
|
|
1557
|
+
const nodes = await resolveMemorySymbolNodes(candidates,
|
|
1558
|
+
{ cwd: root, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText },
|
|
1559
|
+
name => { const current = board.get(task.id); return !current || current.owner !== task.owner || tasks.isPii(current) ? Promise.resolve({ state: "unknown" }) : codeFacts.exists(root, brief.head, name); });
|
|
1560
|
+
memorySymbols.set(key, nodes);
|
|
1561
|
+
}
|
|
1562
|
+
if (reviewSymbols.size > 64) { const oldest = reviewSymbols.keys().next().value!; reviewSymbols.delete(oldest); memorySymbols.delete(oldest); }
|
|
1563
|
+
},
|
|
1564
|
+
memorySymbolNodes: task => {
|
|
1565
|
+
if (tasks.isPii(task) || !task.codeSymbols || task.codeSymbols.head !== task.refs.commit) return undefined;
|
|
1566
|
+
const root = executionRoot(task.owner ?? "");
|
|
1567
|
+
return memorySymbols.get(JSON.stringify([root, task.codeSymbols.head, task.codeSymbols.mergeBase]));
|
|
1465
1568
|
},
|
|
1466
1569
|
reviewBrief: task => {
|
|
1467
1570
|
const root = executionRoot(task.owner ?? "");
|
|
@@ -1479,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1479
1582
|
candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
|
|
1480
1583
|
memoryCheck: checkMemory,
|
|
1481
1584
|
...(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 }),
|
|
1585
|
+
memoryStore ? taskMemoryItems : undefined,
|
|
1586
|
+
(peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
|
|
1484
1587
|
items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
|
|
1485
1588
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
1486
1589
|
checkMemory();
|
|
@@ -1915,11 +2018,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1915
2018
|
cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
|
|
1916
2019
|
...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
|
|
1917
2020
|
},
|
|
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"),
|
|
2021
|
+
report: (peer, window) => budget.report(peer, [{ id: "tokens", kind: "count", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
|
|
2022
|
+
forget: peer => budget.forget(peer, "tokens", "count"),
|
|
1920
2023
|
notice: line => notify(line),
|
|
1921
2024
|
});
|
|
1922
2025
|
kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
|
|
2026
|
+
/** Why a peer shows no number (no quota, nothing read yet): one line each, from the definition and the counters, never from a log's text. */
|
|
2027
|
+
const budgetPeerNotes = (): BudgetPeerNote[] => {
|
|
2028
|
+
const quota = budget.status();
|
|
2029
|
+
const peers = [...new Set([...bus.knownPeers().filter(peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline"), ...Object.keys(quota)])].filter(peer => peer !== USER && peer !== HUB);
|
|
2030
|
+
return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
|
|
2031
|
+
hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
|
|
2032
|
+
};
|
|
1923
2033
|
/** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a usage_update. */
|
|
1924
2034
|
const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
|
|
1925
2035
|
const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
|
|
@@ -2145,7 +2255,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2145
2255
|
}
|
|
2146
2256
|
/** Tokens a peer used, as increments. */
|
|
2147
2257
|
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
2258
|
+
// #285 AC6: a peer with no provider quota (Pi, local) still shows what the hub counted for it in the last 5 h, as information, never as a meter or a gate.
|
|
2259
|
+
const hubTokenWindows = new Map<PeerId, TokenWindow>();
|
|
2260
|
+
const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
|
|
2261
|
+
const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
|
|
2262
|
+
// Replayed through `seed`, which skips rows older than 5 h before inserting any: a long-lived project's whole log costs one pass, not one search per row.
|
|
2263
|
+
const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
|
|
2264
|
+
for (const e of startupEvents) if (e.type === "tokens" && noQuotaPeer(e.peer)) (seedRows.get(e.peer) ?? seedRows.set(e.peer, []).get(e.peer)!).push({ at: Date.parse(e.at), n: e.n });
|
|
2265
|
+
for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
|
|
2148
2266
|
const addTokens = (peer: PeerId, n: number, task?: number): number => {
|
|
2267
|
+
if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
|
|
2149
2268
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
2150
2269
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
2151
2270
|
if (n > 0) {
|
|
@@ -2356,10 +2475,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2356
2475
|
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
2357
2476
|
}
|
|
2358
2477
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2359
|
-
//
|
|
2478
|
+
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
2360
2479
|
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2361
2480
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2362
|
-
// completed-change notice) and
|
|
2481
|
+
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2363
2482
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
2364
2483
|
const target = board.get(Number(a.id));
|
|
2365
2484
|
if (target && !tasks.isPii(target)) throw new PreEffectToolRefusal(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
|
|
@@ -2381,9 +2500,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2381
2500
|
if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
|
|
2382
2501
|
}
|
|
2383
2502
|
if (MEMORY_READ_TOOLS.has(op)) {
|
|
2503
|
+
validateMemoryRead(op, a);
|
|
2384
2504
|
need("memory_read", "read shared memory");
|
|
2505
|
+
if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2385
2506
|
checkMemory();
|
|
2386
|
-
|
|
2507
|
+
const generation = connectionGeneration(by);
|
|
2508
|
+
const valid = () => may(by, "memory_read") && connectionGeneration(by) === generation && !turns.get(by)?.private && !contextPii(by);
|
|
2509
|
+
const symbolStarts = op === "hub_memory_graph" ? (a.starts as string[]).filter(node => node.startsWith("symbol:")) : [];
|
|
2510
|
+
const code = symbolStarts.length ? await memoryCode(symbolStarts, valid).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
2511
|
+
if (!valid()) throw new PreEffectToolRefusal("memory read unavailable");
|
|
2512
|
+
checkMemory();
|
|
2513
|
+
return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
|
|
2387
2514
|
}
|
|
2388
2515
|
if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
|
|
2389
2516
|
if (op === "hub_remember") need("remember", "save notes to shared memory");
|
|
@@ -2568,7 +2695,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2568
2695
|
chmodSync(`${file}.tmp`, 0o600);
|
|
2569
2696
|
renameSync(`${file}.tmp`, file);
|
|
2570
2697
|
pending.done(summary);
|
|
2571
|
-
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project:
|
|
2698
|
+
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: memoryProject, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
|
|
2572
2699
|
notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
|
|
2573
2700
|
return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
|
|
2574
2701
|
}
|
|
@@ -2630,13 +2757,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2630
2757
|
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2631
2758
|
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2632
2759
|
const consoleDeliverables = await tasks.deliverableStatus(task);
|
|
2633
|
-
|
|
2760
|
+
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
2761
|
+
const fresh = board.get(task.id);
|
|
2762
|
+
const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
|
|
2763
|
+
return JSON.stringify({ ...detail, relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2764
|
+
}
|
|
2765
|
+
case "deliverables": {
|
|
2766
|
+
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
2767
|
+
if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
|
|
2768
|
+
return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true }));
|
|
2634
2769
|
}
|
|
2635
2770
|
case "task_assign":
|
|
2636
2771
|
case "task_assign_checked": {
|
|
2637
2772
|
const peer = peerArg(a.peer, "peer");
|
|
2638
2773
|
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
|
|
2774
|
+
if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer" && a.role !== "planner") throw new Error("role must be owner, reviewer or planner");
|
|
2640
2775
|
if (op === "task_assign_checked" && (typeof a.revision !== "number" || !Number.isSafeInteger(a.revision) || a.revision < 0)) throw new Error("revision must be a nonnegative integer");
|
|
2641
2776
|
return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
|
|
2642
2777
|
}
|
|
@@ -2714,7 +2849,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2714
2849
|
else delete defaultSources[peer];
|
|
2715
2850
|
}
|
|
2716
2851
|
};
|
|
2717
|
-
|
|
2852
|
+
// Action allowances have their own host session port; adding local here must not change checkpoints, context telemetry or supervision.
|
|
2853
|
+
const actionSession = (peer: PeerId): string | undefined => {
|
|
2854
|
+
const owner = bus.peers.get(peer);
|
|
2855
|
+
if (isLocalPeer(owner)) { const id = owner.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
2856
|
+
return contextSession(peer);
|
|
2857
|
+
};
|
|
2858
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
|
|
2718
2859
|
// #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
|
|
2719
2860
|
const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
|
|
2720
2861
|
task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
|
|
@@ -2733,11 +2874,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2733
2874
|
return { enforced: true };
|
|
2734
2875
|
}
|
|
2735
2876
|
const owner = bus.peers.get(peer);
|
|
2877
|
+
if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
|
|
2878
|
+
const generation = owner?.sessionGeneration;
|
|
2879
|
+
return owner?.state === "busy" && typeof generation === "string" && generation.length > 0 ? { enforced: true } : { enforced: false, reason: "no current managed Claude implementer child" };
|
|
2880
|
+
}
|
|
2881
|
+
if (isCodexPeer(owner)) return { enforced: false, reason: owner.actionGated ? "Codex approval RPCs are gated, but native policy can execute without sending one (never-ask sends approvalPolicy never)" : "this Codex proxy has no hub approval gate" };
|
|
2882
|
+
if (isLocalPeer(owner)) return owner.state !== "offline" ? { enforced: true } : { enforced: false, reason: "the local worker is offline" };
|
|
2883
|
+
if (isPiPeer(owner)) return owner.state !== "offline" && owner.acceptingTools ? { enforced: true } : { enforced: false, reason: "Pi has no verified live tool bridge" };
|
|
2736
2884
|
if (isAcpPeer(owner) && owner.actionGated) {
|
|
2737
2885
|
const mode = owner.permissionModeState;
|
|
2738
2886
|
return mode === "ask" ? { enforced: true } : { enforced: false, reason: mode === "unknown" || mode === "unmanaged" ? "its native approval mode is not managed by the hub, so a request may never be sent" : `its native mode ${mode} sends no request for a call it allows` };
|
|
2739
2887
|
}
|
|
2740
|
-
return { enforced: false };
|
|
2888
|
+
return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
|
|
2741
2889
|
};
|
|
2742
2890
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2743
2891
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
@@ -3018,6 +3166,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3018
3166
|
...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
|
|
3019
3167
|
startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
|
|
3020
3168
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
3169
|
+
memoryRead: memory.readStatus(),
|
|
3021
3170
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
3022
3171
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
3023
3172
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
@@ -3589,6 +3738,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3589
3738
|
const result = await requestPermission(req, signal);
|
|
3590
3739
|
return result.outcome === "answered" ? result.optionId : undefined;
|
|
3591
3740
|
}
|
|
3741
|
+
/** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
|
|
3742
|
+
async function classedToolPermission(peer: string, root: string, title: string, tool: string | undefined, input: unknown, signal: AbortSignal | undefined, current: () => boolean, observe?: ApprovalObserver): Promise<ToolApproval | undefined> {
|
|
3743
|
+
if (!tool || input === undefined) return undefined;
|
|
3744
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3745
|
+
// runTool executes in this host root and ignores model-supplied cwd/workdir fields.
|
|
3746
|
+
const effective = input && typeof input === "object" && !Array.isArray(input) ? { ...input, cwd: root, workdir: root } : input;
|
|
3747
|
+
let gate: ActionGateVerdict | undefined;
|
|
3748
|
+
try { gate = acpActionGate(peer)({ names: [tool], input: effective }, actionSession(peer) ?? "", title); }
|
|
3749
|
+
catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
|
|
3750
|
+
if (!gate) return undefined;
|
|
3751
|
+
const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
|
|
3752
|
+
if (gate.denyOnly) options.splice(0, 1);
|
|
3753
|
+
if (!gate.denyOnly && gate.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" });
|
|
3754
|
+
const picked = await requestPermission({ peer, title: gate.title, tool, classed: true, options }, signal, observe);
|
|
3755
|
+
if (picked.outcome === "expired") return "expired";
|
|
3756
|
+
if (stopping || signal?.aborted || !current()) return "aborted";
|
|
3757
|
+
if (picked.outcome !== "answered") return false;
|
|
3758
|
+
if (!gate.denyOnly && picked.optionId === ACTION_BUDGET_OPTION) {
|
|
3759
|
+
try { return gate.budget?.approve() === true; } catch { return false; }
|
|
3760
|
+
}
|
|
3761
|
+
return !gate.denyOnly && picked.optionId === "allow";
|
|
3762
|
+
}
|
|
3592
3763
|
async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
|
|
3593
3764
|
const requestEpoch = permissionAnswerEpoch(req.peer);
|
|
3594
3765
|
const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
|
|
@@ -4080,9 +4251,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4080
4251
|
if (adapter?.start.daemonPort === "codex-appserver") {
|
|
4081
4252
|
if (args.unattended && permissionMode(peer) !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask first, or restart without --unattended" };
|
|
4082
4253
|
let codexSteering = false;
|
|
4254
|
+
actionBudgets.revoke(peer);
|
|
4083
4255
|
const codex: CodexPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["codex-appserver"]({
|
|
4084
4256
|
admitAttach: () => membershipAllowed(peer),
|
|
4085
4257
|
permissionMode: permissionMode(peer),
|
|
4258
|
+
actionGate: acpActionGate(peer), onPermission,
|
|
4086
4259
|
onTokens: (added) => void addTokens(peer, added),
|
|
4087
4260
|
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
4088
4261
|
// the id names the hub run, peer and a per-run sequence, so it is unique across restarts. It never carries the native thread: usage telemetry has no session id (docs/events.md).
|
|
@@ -4181,7 +4354,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4181
4354
|
let piReply: Envelope | undefined;
|
|
4182
4355
|
// Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
|
|
4183
4356
|
const piAlways = new Set<string>();
|
|
4184
|
-
|
|
4357
|
+
actionBudgets.revoke(peer);
|
|
4358
|
+
const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4359
|
+
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4360
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
|
|
4361
|
+
if (classed !== undefined) return classed;
|
|
4185
4362
|
if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
|
|
4186
4363
|
const selected = permissionMode(peer);
|
|
4187
4364
|
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 +4408,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4231
4408
|
const nativeTurn = pi.observationTurn;
|
|
4232
4409
|
const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
|
|
4233
4410
|
...(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);
|
|
4411
|
+
permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
|
|
4412
|
+
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
|
|
4236
4413
|
return picked; // never turn a nonempty expiry/abort string into authorization
|
|
4237
4414
|
},
|
|
4238
4415
|
});
|
|
@@ -4342,9 +4519,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4342
4519
|
if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
|
|
4343
4520
|
if (existing) { mute(existing); await existing.stop(); }
|
|
4344
4521
|
const capture = config.memory.enabled
|
|
4345
|
-
? new Capture(memory, { project:
|
|
4522
|
+
? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
|
|
4346
4523
|
: undefined;
|
|
4347
|
-
|
|
4524
|
+
actionBudgets.revoke(peer);
|
|
4525
|
+
const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
|
|
4526
|
+
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4527
|
+
const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
|
|
4528
|
+
if (classed !== undefined) return classed;
|
|
4348
4529
|
if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
|
|
4349
4530
|
const selected = permissionMode(peer);
|
|
4350
4531
|
if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
|
|
@@ -4567,7 +4748,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4567
4748
|
const summary = bus.queueSummary(peer);
|
|
4568
4749
|
return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
|
|
4569
4750
|
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 })) } : {}) };
|
|
4751
|
+
...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source, kind: w.kind, ...(w.superseded ? { superseded: true } : {}) })) } : {}) };
|
|
4571
4752
|
}),
|
|
4572
4753
|
taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
|
|
4573
4754
|
};
|
|
@@ -4664,9 +4845,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4664
4845
|
entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
|
|
4665
4846
|
} catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
|
|
4666
4847
|
}
|
|
4848
|
+
async function uiMemorySnapshot(after: number, input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4849
|
+
const query = input?.memory as Record<string, unknown> | undefined;
|
|
4850
|
+
const start = input?.view === "memory" && query && query.codeNeighbours === true && typeof query.node === "string" && query.node.startsWith("symbol:") && Buffer.byteLength(query.node) <= 512 ? query.node : undefined;
|
|
4851
|
+
const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
|
|
4852
|
+
const snapshot = uiSnapshot(after, input, session);
|
|
4853
|
+
if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
|
|
4854
|
+
const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
|
|
4855
|
+
const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
|
|
4856
|
+
published.notes = published.notes.filter(note => note.tier === tier && (!query?.kind || note.kind === query.kind) && (!query?.peer || note.peer === query.peer) && (!query?.machine || note.machine === query.machine)
|
|
4857
|
+
&& (!query?.since || typeof query.since !== "string" || !note.at || note.at >= query.since)
|
|
4858
|
+
&& (!search || (() => { const row = memoryStore?.get(note.id.slice(4), memoryProject); return !!row && `${row.title ?? ""} ${row.text}`.toLowerCase().includes(search); })()));
|
|
4859
|
+
Object.assign(snapshot.memory, { code: published });
|
|
4860
|
+
}
|
|
4861
|
+
return snapshot;
|
|
4862
|
+
}
|
|
4667
4863
|
function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
|
|
4668
4864
|
const quota = budget.status();
|
|
4669
4865
|
const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
|
|
4866
|
+
const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
|
|
4670
4867
|
// #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
|
|
4671
4868
|
// so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
|
|
4672
4869
|
const turnView = (peer: PeerId) => {
|
|
@@ -4744,10 +4941,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4744
4941
|
tasks: boardTasks.map((task) => {
|
|
4745
4942
|
const view = tasks.publicView(task);
|
|
4746
4943
|
// 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 } : {}) };
|
|
4944
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}), ...(() => { const deliverables = tasks.deliverableBadge(task); return deliverables ? { deliverables } : {}; })() };
|
|
4748
4945
|
}),
|
|
4749
4946
|
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))) }])),
|
|
4947
|
+
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), ...(budgetNoteOf.get(id) ? { note: budgetNoteOf.get(id)!.note, noteKind: budgetNoteOf.get(id)!.kind } : {}), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
4751
4948
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
4752
4949
|
const req = JSON.parse(push);
|
|
4753
4950
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -4804,7 +5001,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4804
5001
|
function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
|
|
4805
5002
|
// A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
|
|
4806
5003
|
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));
|
|
5004
|
+
const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled), peer => plannerEligible.has(peer));
|
|
4808
5005
|
return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
|
|
4809
5006
|
}
|
|
4810
5007
|
function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
|
|
@@ -5061,7 +5258,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5061
5258
|
},
|
|
5062
5259
|
});
|
|
5063
5260
|
const undo = pendingUndo(opts.stateDir);
|
|
5064
|
-
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it
|
|
5261
|
+
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it pins the listed peers as the only planners; new tasks select one of those holders. Agents cannot change this setting.`;
|
|
5065
5262
|
return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
5066
5263
|
}
|
|
5067
5264
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
@@ -5477,6 +5674,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5477
5674
|
if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
|
|
5478
5675
|
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
|
|
5479
5676
|
if (extraIds.some(id => adapterCatalog.definitionOf(declarations[id]!.kind)?.transport === "claude-channel" && declarations[id]!.roles?.includes("implementer")) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("role_stages"))) return "target lacks role_stages; declared Claude implementer authority cannot be restored by that daemon";
|
|
5677
|
+
if ((Array.isArray(config.planner) || typeof config.planner === "string" && extraIds.includes(config.planner) || extraIds.some(id => declarations[id]!.roles?.includes("planner")) || board.hasPlannerState())
|
|
5678
|
+
&& (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
|
|
5480
5679
|
if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
|
|
5481
5680
|
if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
|
|
5482
5681
|
return undefined;
|
|
@@ -6263,7 +6462,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6263
6462
|
case "ui":
|
|
6264
6463
|
if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
|
|
6265
6464
|
try {
|
|
6266
|
-
dashboard ??= startDashboard({ snapshot:
|
|
6465
|
+
dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
|
|
6267
6466
|
writeStatus();
|
|
6268
6467
|
// #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
|
|
6269
6468
|
if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
|
|
@@ -6352,7 +6551,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6352
6551
|
case "ui_snapshot":
|
|
6353
6552
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
6354
6553
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
6355
|
-
|
|
6554
|
+
void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
|
|
6555
|
+
return;
|
|
6356
6556
|
case "ui_action":
|
|
6357
6557
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
6358
6558
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -6480,7 +6680,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6480
6680
|
// Screened before the caps: withheld entries never crowd out readable evidence.
|
|
6481
6681
|
const hits = screenMemory(memoryStore!.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER })).map((v) => [v, score(v)] as const).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([v]) => v);
|
|
6482
6682
|
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
|
|
6683
|
+
return [...hits, ...near].map((v) => ({ id: `mem:${v.id}`, text: memoryLine(v).slice(13) }));
|
|
6484
6684
|
} } : {}),
|
|
6485
6685
|
project: chain.at(-1)!,
|
|
6486
6686
|
logFile,
|
|
@@ -6500,7 +6700,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6500
6700
|
project: chain.at(-1)!,
|
|
6501
6701
|
metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
|
|
6502
6702
|
});
|
|
6503
|
-
saved = ok ? "saved
|
|
6703
|
+
saved = ok ? "saved as a candidate model-written answer; a person must review it" : "memory store unavailable; nothing saved";
|
|
6504
6704
|
}
|
|
6505
6705
|
}
|
|
6506
6706
|
reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
|
|
@@ -6510,7 +6710,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6510
6710
|
case "budget":
|
|
6511
6711
|
if (c.role !== "console") {
|
|
6512
6712
|
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 });
|
|
6713
|
+
return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6514
6714
|
}
|
|
6515
6715
|
if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
|
|
6516
6716
|
if (msg.resume) {
|
|
@@ -6519,9 +6719,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6519
6719
|
const used = Number(msg.set.used);
|
|
6520
6720
|
if (!bus.peers.has(String(msg.set.peer)) && !budget.record(String(msg.set.peer))) return void reply({ t: "budget", ok: false, error: `unknown peer: ${msg.set.peer}` });
|
|
6521
6721
|
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" }]);
|
|
6722
|
+
budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", kind: "manual", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
|
|
6523
6723
|
}
|
|
6524
|
-
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6724
|
+
return void reply({ t: "budget", ok: true, budget: budget.status(), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
|
|
6525
6725
|
case "usage": {
|
|
6526
6726
|
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6527
6727
|
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
@@ -6626,6 +6826,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6626
6826
|
executionBudget.close();
|
|
6627
6827
|
syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
|
|
6628
6828
|
await syncRun?.catch(() => undefined);
|
|
6829
|
+
memory.close();
|
|
6629
6830
|
candidates?.close();
|
|
6630
6831
|
memoryStore?.close();
|
|
6631
6832
|
board.close();
|