@staix/agent-hub 0.12.36 → 0.12.38
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 +22 -0
- package/README.md +18 -11
- package/docs/agent-notes/adapters.md +19 -6
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/tasks.md +8 -4
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +57 -10
- package/docs/quickstart.md +15 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +38 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +27 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5627 -3670
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +290 -0
- package/src/adapters/definitions/builtin-metadata.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +33 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +36 -4
- package/src/adapters/registry.ts +28 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +382 -0
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +20 -0
- package/src/cli/memory.ts +13 -12
- package/src/cli/output.ts +5 -0
- package/src/hub/board.ts +34 -6
- package/src/hub/bus.ts +17 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +266 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +76 -0
- package/src/hub/routing.ts +45 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +261 -36
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { NativeHookLaunches } from "./native-hook-launch.ts";
|
|
2
|
+
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
1
3
|
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
2
4
|
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
3
5
|
import { isDenied } from "../local/deny.ts";
|
|
@@ -23,11 +25,11 @@ import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
|
23
25
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
24
26
|
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
25
27
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
26
|
-
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
28
|
+
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
27
29
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
28
30
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
29
|
-
import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
30
|
-
import { definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
31
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
32
|
+
import { isSupportedPeer as registrySupportedPeer, definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
31
33
|
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
32
34
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
33
35
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
@@ -63,7 +65,10 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
63
65
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
64
66
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
65
67
|
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
66
|
-
import {
|
|
68
|
+
import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
69
|
+
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
70
|
+
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
71
|
+
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
67
72
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
68
73
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
69
74
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
@@ -92,7 +97,7 @@ import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts"
|
|
|
92
97
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
93
98
|
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
94
99
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
95
|
-
import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
100
|
+
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
96
101
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
97
102
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
98
103
|
import { VERSION } from "../version.ts";
|
|
@@ -118,10 +123,10 @@ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type Contex
|
|
|
118
123
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
119
124
|
import { detectTooling } from "./tooling.ts";
|
|
120
125
|
import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
|
|
121
|
-
import {
|
|
126
|
+
import { memoryProjectKey } from "@staix/agent-mem";
|
|
127
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
122
128
|
import { syncMemory } from "@staix/agent-mem";
|
|
123
129
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
124
|
-
import { memoryProjectKey } from "@staix/agent-mem";
|
|
125
130
|
import { nativeRecall } from "../memory/recall.ts";
|
|
126
131
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
127
132
|
|
|
@@ -184,7 +189,7 @@ export interface HubConfig {
|
|
|
184
189
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
185
190
|
limits: LimitsConfig;
|
|
186
191
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
187
|
-
review: { adaptive: boolean; min_reviews: number };
|
|
192
|
+
review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
|
|
188
193
|
/** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
|
|
189
194
|
recovery: { auto_resume_after_crash: boolean };
|
|
190
195
|
/** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
|
|
@@ -252,16 +257,26 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
|
|
|
252
257
|
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
|
|
253
258
|
|
|
254
259
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
255
|
-
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
|
|
260
|
+
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
256
261
|
const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
|
|
257
262
|
const conductor = { ...DEFAULT_CONFIG.conductor };
|
|
263
|
+
const notices: string[] = [];
|
|
258
264
|
let planner: string | undefined;
|
|
259
265
|
for (const name of CONFIG_FILES) {
|
|
260
266
|
let file: Record<string, unknown>;
|
|
261
267
|
if (layers && !layers.some(layer => layer.name === name)) continue;
|
|
262
268
|
try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
|
|
263
269
|
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
264
|
-
if (file.roles !== undefined) {
|
|
270
|
+
if (file.roles !== undefined) {
|
|
271
|
+
if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
|
|
272
|
+
for (const [peer, assigned] of Object.entries(file.roles)) {
|
|
273
|
+
conductorPeer({ [peer]: assigned });
|
|
274
|
+
const ceiling = definitionOf(peer)?.routing?.allowedRoles;
|
|
275
|
+
const declared = assigned as string[];
|
|
276
|
+
roles[peer] = ceiling ? declared.filter(role => ceiling.includes(role)) : declared;
|
|
277
|
+
if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
265
280
|
if (file.planner !== undefined) {
|
|
266
281
|
if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
|
|
267
282
|
planner = file.planner as string;
|
|
@@ -280,7 +295,7 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
280
295
|
}
|
|
281
296
|
}
|
|
282
297
|
conductorPeer(roles);
|
|
283
|
-
return { roles, conductor, ...(planner ? { planner } : {}) };
|
|
298
|
+
return { roles, conductor, ...(planner ? { planner } : {}), ...(notices.length ? { notices } : {}) };
|
|
284
299
|
}
|
|
285
300
|
|
|
286
301
|
/** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
|
|
@@ -361,6 +376,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
|
|
|
361
376
|
}
|
|
362
377
|
const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
363
378
|
const conductorPolicy = loadConductorPolicy(cwd, captured);
|
|
379
|
+
retired.push(...conductorPolicy.notices ?? []);
|
|
364
380
|
const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
|
|
365
381
|
const instancePermissions: Record<string, PermissionMode> = {};
|
|
366
382
|
const instanceModes: PeerStartConfig = {};
|
|
@@ -438,6 +454,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
438
454
|
}
|
|
439
455
|
|
|
440
456
|
export interface DaemonOptions {
|
|
457
|
+
/** Host-only admission transport; never selected by project config or tools. */
|
|
458
|
+
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
441
459
|
projectId?: string;
|
|
442
460
|
instanceId?: string;
|
|
443
461
|
cwd: string;
|
|
@@ -501,8 +519,18 @@ class WsPeer extends BasePeer {
|
|
|
501
519
|
steer: PeerAdapter["steer"] = undefined;
|
|
502
520
|
lastInboxAt: number | undefined;
|
|
503
521
|
private claimedPullOnly = false;
|
|
522
|
+
private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
|
|
523
|
+
private settleAttachmentWaiters(attached: boolean): void {
|
|
524
|
+
for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
|
|
525
|
+
}
|
|
526
|
+
/** Bounded native-hook observations wait for this exact claim, never a later socket. */
|
|
527
|
+
waitForAttach(generation: string): Promise<boolean> {
|
|
528
|
+
if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
|
|
529
|
+
return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
|
|
530
|
+
}
|
|
504
531
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
505
532
|
claim(sock: Sock, pullOnly = false): void {
|
|
533
|
+
this.settleAttachmentWaiters(false);
|
|
506
534
|
this.claimGeneration = crypto.randomUUID();
|
|
507
535
|
this.claimed = sock;
|
|
508
536
|
this.claimedPullOnly = pullOnly;
|
|
@@ -534,9 +562,10 @@ class WsPeer extends BasePeer {
|
|
|
534
562
|
};
|
|
535
563
|
this.lastInboxAt = undefined;
|
|
536
564
|
this.setState("idle");
|
|
565
|
+
this.settleAttachmentWaiters(true);
|
|
537
566
|
}
|
|
538
567
|
detach(sock: Sock): void {
|
|
539
|
-
if (this.claimed === sock) this.claimed = undefined;
|
|
568
|
+
if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
|
|
540
569
|
if (this.sock !== sock) return;
|
|
541
570
|
this.sock = undefined;
|
|
542
571
|
this.steer = undefined;
|
|
@@ -545,6 +574,7 @@ class WsPeer extends BasePeer {
|
|
|
545
574
|
this.setState("offline");
|
|
546
575
|
}
|
|
547
576
|
detachForRoster(): void {
|
|
577
|
+
this.settleAttachmentWaiters(false);
|
|
548
578
|
this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
|
|
549
579
|
this.sock?.close(4000, "peer is off roster; retry when a person adds it");
|
|
550
580
|
this.claimed = undefined;
|
|
@@ -586,6 +616,7 @@ class WsPeer extends BasePeer {
|
|
|
586
616
|
ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
|
|
587
617
|
async start(): Promise<void> {}
|
|
588
618
|
async stop(): Promise<void> {
|
|
619
|
+
this.settleAttachmentWaiters(false);
|
|
589
620
|
this.sock?.close();
|
|
590
621
|
}
|
|
591
622
|
|
|
@@ -601,6 +632,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
601
632
|
let ready = false;
|
|
602
633
|
try {
|
|
603
634
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
635
|
+
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
604
636
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
605
637
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
606
638
|
const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
|
|
@@ -612,6 +644,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
612
644
|
const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
|
|
613
645
|
const executionRoots = new Map<string, string>();
|
|
614
646
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
647
|
+
const reviewerUnavailable = new Map<string, string>();
|
|
615
648
|
const roles = { ...loadedConfig.roles };
|
|
616
649
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
617
650
|
const startModesFromInstances: PeerStartConfig = {};
|
|
@@ -635,7 +668,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
635
668
|
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
636
669
|
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
637
670
|
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
638
|
-
const isSupportedPeer = (peer: unknown): peer is string =>
|
|
671
|
+
const isSupportedPeer = (peer: unknown): peer is string => registrySupportedPeer(peer, declarations);
|
|
639
672
|
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
640
673
|
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
641
674
|
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
@@ -675,6 +708,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
675
708
|
let configRevision = 0;
|
|
676
709
|
let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
|
|
677
710
|
let pendingRestart: { key: string; reason: string }[] = [];
|
|
711
|
+
let retainedConfig: { key: string; reason: string }[] = [];
|
|
712
|
+
function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
|
|
713
|
+
const rows = new Map<string, string>();
|
|
714
|
+
if (scope) for (const row of retainedConfig) {
|
|
715
|
+
if (row.key === scope) continue;
|
|
716
|
+
const route = row.key.startsWith("routing."), path = (route ? row.key.slice(8) : row.key).split(".");
|
|
717
|
+
const current = valueAt(route ? sources.routing : fileConfig, path), accepted = valueAt(route ? desiredRouting : desiredConfig, path);
|
|
718
|
+
if (JSON.stringify(current) !== JSON.stringify(accepted)) rows.set(row.key, row.reason);
|
|
719
|
+
}
|
|
720
|
+
for (const row of rejected) rows.set(sanitize(row.key).slice(0, 160), sanitize(row.reason).slice(0, 1200));
|
|
721
|
+
return [...rows].map(([key, reason]) => ({ key, reason })).sort((left, right) => left.key.localeCompare(right.key));
|
|
722
|
+
}
|
|
723
|
+
function retainedLaunchConfig(peer: string): Record<string, unknown> | undefined {
|
|
724
|
+
const keys = definitionOf(peer)?.cli.launchConfigKeys ?? [];
|
|
725
|
+
const pending = retainedConfig.filter(row => keys.some(key => row.key === key || row.key.startsWith(`${key}.`)));
|
|
726
|
+
if (!pending.length) return;
|
|
727
|
+
return Object.fromEntries(pending.map(row => [row.key, valueAt(config, row.key.split("."))]));
|
|
728
|
+
}
|
|
729
|
+
const includeRetainedLaunchConfig = (peer: string, result: Record<string, unknown>): Record<string, unknown> => {
|
|
730
|
+
const values = retainedLaunchConfig(peer);
|
|
731
|
+
return values ? { ...result, retainedLaunchConfig: values } : result;
|
|
732
|
+
};
|
|
678
733
|
let lastReloadFailure: string | undefined;
|
|
679
734
|
let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
680
735
|
let configWatchPending = false;
|
|
@@ -780,6 +835,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
780
835
|
// Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
|
|
781
836
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
782
837
|
const token = randomBytes(24).toString("hex");
|
|
838
|
+
const nativeHookLaunches = new NativeHookLaunches();
|
|
783
839
|
|
|
784
840
|
const server = listenWithoutPortCollision(() => Bun.serve<Client>({
|
|
785
841
|
hostname: "127.0.0.1",
|
|
@@ -807,6 +863,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
807
863
|
}
|
|
808
864
|
},
|
|
809
865
|
close(sock) {
|
|
866
|
+
nativeHookLaunches.endOwner(sock);
|
|
810
867
|
consoles.delete(sock);
|
|
811
868
|
sock.data.tail?.();
|
|
812
869
|
sock.data.watch?.();
|
|
@@ -977,7 +1034,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
977
1034
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
978
1035
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
979
1036
|
// normalized origin remote, the same on every machine; without one, its path). Fail-open: without it the hub works as before.
|
|
980
|
-
|
|
1037
|
+
// Daemon start never depends on the memory home: an unresolvable home falls back to the raw key.
|
|
1038
|
+
const memoryProjectRaw = memoryProjectKey(opts.cwd);
|
|
1039
|
+
const memoryProject = (() => { try { return memoryProjectFor(opts.cwd); } catch { return memoryProjectRaw; } })();
|
|
981
1040
|
let memoryStore: MemoryStore | undefined;
|
|
982
1041
|
let memoryBinding: MemoryProfileBinding | undefined;
|
|
983
1042
|
let memoryProblem: string | undefined;
|
|
@@ -989,11 +1048,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
989
1048
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
990
1049
|
};
|
|
991
1050
|
if (config.memory.native) {
|
|
992
|
-
try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
|
|
1051
|
+
try { const opened = openProfileMemory({ project: memoryProject, projectKey: memoryProjectRaw, ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
|
|
993
1052
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
994
1053
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
995
1054
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
996
|
-
catch { if (!memoryProblem) memoryUnavailable("memory profile unavailable; inspect the selector and generation, then restart the hub"); }
|
|
1055
|
+
catch (error) { if (!memoryProblem) memoryUnavailable(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : "memory profile unavailable; inspect the selector and generation, then restart the hub"); }
|
|
997
1056
|
}
|
|
998
1057
|
const memoryProfileStatus = (person = false) => {
|
|
999
1058
|
const value = memoryBinding?.status(memoryStore) ?? ({
|
|
@@ -1292,7 +1351,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1292
1351
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
1293
1352
|
}));
|
|
1294
1353
|
};
|
|
1354
|
+
const releaseQuotaSnapshot = () => {
|
|
1355
|
+
const headroom = budget.headroom(), rows = [...bus.peers].filter(([, peer]) => peer.state !== "offline");
|
|
1356
|
+
const reasons: string[] = [], notes: string[] = [];
|
|
1357
|
+
for (const [id] of rows) {
|
|
1358
|
+
const definition = definitionOf(id), account = definition?.budget?.account ?? id;
|
|
1359
|
+
if (manualPaused.has(id)) reasons.push(`${id}: manual pause; ahub resume ${id}`);
|
|
1360
|
+
if (budget.record(account)) reasons.push(`${id}: paused quota window; inspect ahub budget or ahub budget resume ${id}`);
|
|
1361
|
+
if (definition && (!definition.budget || definition.budget.quotaExempt)) continue;
|
|
1362
|
+
// Source availability belongs to the shared account, including its offline base and reviewer variants.
|
|
1363
|
+
// The soft token window uses the same definition capability that resets it on resume.
|
|
1364
|
+
const hasSource = accountMembers(account).some(member => {
|
|
1365
|
+
const policy = definitionOf(member)?.budget;
|
|
1366
|
+
return !!policy?.reader || !!policy?.resetSoftTokensOnResume && config.budget.kimi_tokens_5h > 0;
|
|
1367
|
+
});
|
|
1368
|
+
if (definition && !hasSource) {
|
|
1369
|
+
notes.push(`${id}: no quota source`);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const reading = headroom[account];
|
|
1373
|
+
if (!reading || !Number.isFinite(reading.headroom)) reasons.push(`${id}: quota window unknown or stale; inspect ahub budget or ahub budget set ${id} <0..1>`);
|
|
1374
|
+
else if (reading.headroom <= 1 - config.budget.gate) reasons.push(`${id}: quota window below release headroom; wait for reset or inspect ahub budget set ${id} <0..1>`);
|
|
1375
|
+
}
|
|
1376
|
+
const idle = rows.every(([, peer]) => peer.state === "idle");
|
|
1377
|
+
return { quota: reasons.length === 0, idle, reasons, notes, signature: JSON.stringify({ gate: config.budget.gate, softTokens: config.budget.kimi_tokens_5h, rows: rows.map(([id, peer]) => [id, peer.state]), headroom, paused: rows.map(([id]) => manualPaused.has(id)), records: Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused?.since, row.paused?.resetsAt]) }) };
|
|
1378
|
+
};
|
|
1379
|
+
const releaseWindow = async () => {
|
|
1380
|
+
const host = await (opts.releaseHostAdmission ?? readReleaseHostAdmission)(opts.cwd), quota = releaseQuotaSnapshot();
|
|
1381
|
+
if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
|
|
1382
|
+
return { at: Date.now(), host: host.admitted && !stopping && !recoveryActive(), quota: quota.quota, idle: quota.idle, reasons: [...(host.reason ? [host.reason] : []), ...quota.reasons, ...quota.notes], close: host.close, current: () => host.current?.() !== false && !stopping && !recoveryActive() && releaseQuotaSnapshot().signature === quota.signature };
|
|
1383
|
+
};
|
|
1384
|
+
// Bind both legacy print transports and task-MCP calls to the child's original review request.
|
|
1385
|
+
const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
|
|
1386
|
+
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1387
|
+
const attached=bus.peers.get(by), peer=attached instanceof ReviewerPrintPeer?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
|
|
1388
|
+
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
1389
|
+
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
1390
|
+
};
|
|
1295
1391
|
const tasks: Tasks = new Tasks({
|
|
1392
|
+
releaseWindow,
|
|
1296
1393
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1297
1394
|
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1298
1395
|
const codes: ("approvals" | "quota")[] = [];
|
|
@@ -1303,13 +1400,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1303
1400
|
instances: () => declarations,
|
|
1304
1401
|
reviewOnly: reviewOnlyPeer,
|
|
1305
1402
|
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1306
|
-
reviewAuthority:
|
|
1307
|
-
if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
|
|
1308
|
-
const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
|
|
1309
|
-
return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
|
|
1310
|
-
},
|
|
1403
|
+
reviewAuthority: runningReviewAuthority,
|
|
1311
1404
|
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1312
|
-
reviewContext: (by, task) => definitionOf(by)?.
|
|
1405
|
+
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1313
1406
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1314
1407
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1315
1408
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
@@ -1403,7 +1496,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1403
1496
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
1404
1497
|
// The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
|
|
1405
1498
|
recordSplit: (task, p, where) => event({ type: "split", task, where, verdict: p.verdict, ...(p.single ? { single: p.single, splitS: p.splitS, singleS: p.singleS } : {}), ...(p.verdict === "unknown" ? { reason: p.trace.at(-1)!.replace(/^ {2}unknown: /, "").slice(0, 200) } : {}), trace: p.trace.slice(1).map((l) => l.trim()).slice(0, 10) }),
|
|
1406
|
-
failing: () => bus.failingPeers(),
|
|
1499
|
+
failing: () => ({ ...bus.failingPeers(), ...Object.fromEntries(reviewerUnavailable) }),
|
|
1407
1500
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
1408
1501
|
});
|
|
1409
1502
|
relevantNotice = (peer, env) => {
|
|
@@ -1977,7 +2070,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1977
2070
|
const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
|
|
1978
2071
|
taskOpsInFlight++;
|
|
1979
2072
|
try {
|
|
2073
|
+
const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
|
|
2074
|
+
const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
|
|
1980
2075
|
const text = await taskOpBody(...args);
|
|
2076
|
+
const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
|
|
1981
2077
|
if (conducted) {
|
|
1982
2078
|
const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
|
|
1983
2079
|
const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
|
|
@@ -2022,7 +2118,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2022
2118
|
}
|
|
2023
2119
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2024
2120
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
2025
|
-
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_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`);
|
|
2121
|
+
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.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`);
|
|
2026
2122
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2027
2123
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2028
2124
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
@@ -2136,6 +2232,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2136
2232
|
}
|
|
2137
2233
|
case "hub_backlog_list":
|
|
2138
2234
|
return JSON.stringify(tasks.backlogList(by));
|
|
2235
|
+
case "hub_release_new":
|
|
2236
|
+
return JSON.stringify(tasks.publicRelease(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order })));
|
|
2237
|
+
case "hub_release_report":
|
|
2238
|
+
return JSON.stringify(tasks.releaseReport(by, a.id));
|
|
2239
|
+
case "hub_release_configure":
|
|
2240
|
+
return JSON.stringify(tasks.publicRelease(await tasks.configureRelease(by, { id: a.id, revision: a.revision, hotfix: a.hotfix, state: a.state, cut: a.cut })));
|
|
2241
|
+
case "hub_release_assign":
|
|
2242
|
+
return JSON.stringify(tasks.publicView(await tasks.assignRelease(by, { id: a.id, revision: a.revision, release: a.release, reason: a.reason })));
|
|
2243
|
+
case "hub_release_cut":
|
|
2244
|
+
return JSON.stringify(tasks.publicRelease(await tasks.cutRelease(by, { id: a.id, revision: a.revision })));
|
|
2139
2245
|
case "hub_release_list":
|
|
2140
2246
|
return JSON.stringify(tasks.releaseList(by));
|
|
2141
2247
|
case "hub_planner_groom":
|
|
@@ -2586,6 +2692,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2586
2692
|
version: VERSION,
|
|
2587
2693
|
pluginBundle: SERVER_JS,
|
|
2588
2694
|
pendingRestart,
|
|
2695
|
+
retainedConfig,
|
|
2696
|
+
startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
|
|
2589
2697
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2590
2698
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
2591
2699
|
// ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
|
|
@@ -3245,7 +3353,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3245
3353
|
const owner = bus.peers.get(peer);
|
|
3246
3354
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3247
3355
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3248
|
-
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof
|
|
3356
|
+
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3249
3357
|
if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3250
3358
|
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3251
3359
|
if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
|
|
@@ -3309,6 +3417,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3309
3417
|
|
|
3310
3418
|
async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
|
|
3311
3419
|
const adapter = definitionOf(peer);
|
|
3420
|
+
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3312
3421
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3313
3422
|
if (preflight) return { ok: false, error: preflight };
|
|
3314
3423
|
if (adapter?.attach?.admission && !declarations[peer]) {
|
|
@@ -3803,24 +3912,54 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3803
3912
|
await local.start();
|
|
3804
3913
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
3805
3914
|
}
|
|
3806
|
-
if (adapter?.
|
|
3807
|
-
|
|
3915
|
+
if (adapter?.reviewer) {
|
|
3916
|
+
const backend = adapter.reviewer.backend;
|
|
3917
|
+
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
3918
|
+
// #519: each backend proves isolation before admission, then starts one fresh child per review.
|
|
3808
3919
|
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
3809
3920
|
if (existing) { mute(existing); await existing.stop(); }
|
|
3810
|
-
const
|
|
3811
|
-
|
|
3921
|
+
const reviewPolicies = new Map<string, string>();
|
|
3922
|
+
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
|
|
3923
|
+
backend, cwd: executionCwd,
|
|
3812
3924
|
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
3813
3925
|
systemPrompt: sessionRoleContract(peer),
|
|
3814
3926
|
watchdogMs: config.watchdog_ms,
|
|
3815
|
-
privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3927
|
+
privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3928
|
+
bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
|
|
3929
|
+
taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
|
|
3930
|
+
call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)) }),
|
|
3931
|
+
admit: env => {
|
|
3932
|
+
if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
|
|
3933
|
+
{ head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
|
|
3934
|
+
const policy = JSON.stringify([config.local.deny, config.pii, config.data, config.roles[peer]]);
|
|
3935
|
+
const before = reviewPolicies.get(env.id);
|
|
3936
|
+
if (before !== undefined) return before === policy;
|
|
3937
|
+
if (reviewPolicies.size >= 64) reviewPolicies.delete(reviewPolicies.keys().next().value!);
|
|
3938
|
+
reviewPolicies.set(env.id, policy);
|
|
3939
|
+
return true;
|
|
3940
|
+
},
|
|
3816
3941
|
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 }),
|
|
3817
3942
|
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
3818
3943
|
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
3944
|
+
onUnavailable: (failure, current) => {
|
|
3945
|
+
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
3946
|
+
if (!attached()) return;
|
|
3947
|
+
if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
|
|
3948
|
+
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
3949
|
+
},
|
|
3819
3950
|
log,
|
|
3820
|
-
}) }) as
|
|
3951
|
+
}) }) as ReviewerPrintPeer;
|
|
3952
|
+
try { await reviewer.start(); }
|
|
3953
|
+
catch (error) {
|
|
3954
|
+
await reviewer.stop().catch(() => {});
|
|
3955
|
+
const reason = backend.nativePolicy && error instanceof Error ? sanitize(error.message).slice(0, 1000)
|
|
3956
|
+
: "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
|
|
3957
|
+
reviewerUnavailable.set(peer, reason);
|
|
3958
|
+
return { ok: false, error: reason };
|
|
3959
|
+
}
|
|
3960
|
+
reviewerUnavailable.delete(peer);
|
|
3821
3961
|
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
3822
3962
|
bus.add(reviewer);
|
|
3823
|
-
await reviewer.start();
|
|
3824
3963
|
return { ok: true };
|
|
3825
3964
|
}
|
|
3826
3965
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
@@ -4022,7 +4161,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4022
4161
|
instanceId,
|
|
4023
4162
|
attention: attentionStatus.attentionInput,
|
|
4024
4163
|
pet,
|
|
4025
|
-
status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4164
|
+
status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
|
|
4026
4165
|
boardCapabilities: { backlog: true },
|
|
4027
4166
|
planningCapabilities: { enabled: true, wipPull: true, split: true },
|
|
4028
4167
|
releases: tasks.releaseList(USER),
|
|
@@ -4272,6 +4411,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4272
4411
|
if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
|
|
4273
4412
|
pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
|
|
4274
4413
|
pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
|
|
4414
|
+
retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
|
|
4275
4415
|
storedConfigCache = undefined;
|
|
4276
4416
|
if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
|
|
4277
4417
|
bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
|
|
@@ -4280,7 +4420,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4280
4420
|
if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
|
|
4281
4421
|
const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
|
|
4282
4422
|
const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
|
|
4283
|
-
if (failure && lastReloadFailure !== failure) { try { notify(`
|
|
4423
|
+
if (failure && lastReloadFailure !== failure) { try { notify(`config retained: ${retainedConfig.map(item => item.key).join(", ")}; run ahub settings reload`); } catch { log("configuration retained notice unavailable"); } }
|
|
4284
4424
|
lastReloadFailure = failure;
|
|
4285
4425
|
writeStatus();
|
|
4286
4426
|
const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
|
|
@@ -4316,7 +4456,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4316
4456
|
try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
|
|
4317
4457
|
catch { return {}; }
|
|
4318
4458
|
}
|
|
4319
|
-
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
|
|
4459
|
+
function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
|
|
4320
4460
|
const stored = storedConfig();
|
|
4321
4461
|
// What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
|
|
4322
4462
|
// merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
|
|
@@ -4335,7 +4475,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4335
4475
|
});
|
|
4336
4476
|
const undo = pendingUndo(opts.stateDir);
|
|
4337
4477
|
const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
|
|
4338
|
-
return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
4478
|
+
return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
|
|
4339
4479
|
}
|
|
4340
4480
|
/** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
|
|
4341
4481
|
const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
|
|
@@ -4686,16 +4826,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4686
4826
|
return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
|
|
4687
4827
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4688
4828
|
};
|
|
4829
|
+
const incompatibleRecoveryTarget = () => {
|
|
4830
|
+
const cancelled = incompatibleCancellationTarget();
|
|
4831
|
+
if (cancelled) return cancelled;
|
|
4832
|
+
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";
|
|
4833
|
+
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";
|
|
4834
|
+
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";
|
|
4835
|
+
return undefined;
|
|
4836
|
+
};
|
|
4689
4837
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4690
4838
|
if (checkTarget) {
|
|
4691
|
-
const error =
|
|
4692
|
-
if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return recoveryError("target lacks peer_instances; declared instance identities cannot be restored by that daemon");
|
|
4839
|
+
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
4693
4840
|
}
|
|
4694
4841
|
|
|
4695
4842
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4696
4843
|
for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4697
4844
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4698
|
-
if (checkTarget) { const error =
|
|
4845
|
+
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4699
4846
|
}
|
|
4700
4847
|
}
|
|
4701
4848
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
@@ -4711,7 +4858,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4711
4858
|
recoveryPeerSnapshot = undefined;
|
|
4712
4859
|
}
|
|
4713
4860
|
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
4714
|
-
{ const error =
|
|
4861
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4715
4862
|
recoveryOperationId = op;
|
|
4716
4863
|
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
4717
4864
|
armRecoveryLease();
|
|
@@ -4719,7 +4866,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4719
4866
|
budget.setRecoveryHold(true);
|
|
4720
4867
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4721
4868
|
await bus.fenceRecovery();
|
|
4722
|
-
{ const error =
|
|
4869
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4723
4870
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
4724
4871
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
4725
4872
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4746,6 +4893,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4746
4893
|
}
|
|
4747
4894
|
if (msg.op === "commit") {
|
|
4748
4895
|
if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
|
|
4896
|
+
// Person ruling 2026-10-19: upgrade is excluded from release resource gates; original readiness still binds.
|
|
4749
4897
|
recoveryPhase = "prepared";
|
|
4750
4898
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4751
4899
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4768,7 +4916,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4768
4916
|
peers,
|
|
4769
4917
|
integrity: integrity(),
|
|
4770
4918
|
};
|
|
4771
|
-
{ const error =
|
|
4919
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4772
4920
|
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
4773
4921
|
recoveryCommitted = true;
|
|
4774
4922
|
clearTimeout(recoveryLeaseTimer);
|
|
@@ -4935,6 +5083,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4935
5083
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
4936
5084
|
} else {
|
|
4937
5085
|
c.peer = String(msg.peer ?? "claude");
|
|
5086
|
+
if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
|
|
4938
5087
|
const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
|
|
4939
5088
|
if (!PEER_ID.test(c.peer) || (RESERVED_IDS.has(c.peer) && !kimiPull)) return sock.close(4403, definitionOf(c.peer)?.attach?.admission?.reservedRefusal ?? "peer id is reserved or malformed");
|
|
4940
5089
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
@@ -4961,7 +5110,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4961
5110
|
}
|
|
4962
5111
|
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
|
|
4963
5112
|
}
|
|
4964
|
-
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
5113
|
+
if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
|
|
4965
5114
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
4966
5115
|
switch (msg.t) {
|
|
4967
5116
|
case "permission_default": {
|
|
@@ -5078,16 +5227,87 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5078
5227
|
const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
|
|
5079
5228
|
const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
|
|
5080
5229
|
if (refused) return void reply({ t: "sent", ok: false, error: refused });
|
|
5081
|
-
const targets = bus.
|
|
5230
|
+
const { targets, dropped } = bus.publishResult(env);
|
|
5082
5231
|
if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
|
|
5232
|
+
if (dropped === "hop") return void reply({ t: "sent", ok: true, targets, dropped, recorded: priority === "fyi",
|
|
5233
|
+
notice: `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to` });
|
|
5083
5234
|
if (hushed.length && !targets.length) return void reply({ t: "sent", ok: false, error: `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` });
|
|
5084
5235
|
return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi", ...(offRoster.length ? { offRoster } : {}), ...(hushed.length ? { hushed: hushed.map((h) => h.peer) } : {}), ...((offRoster.length || hushed.length) ? { notice: [offRoster.length ? `retained off roster for ${offRoster.join(", ")}` : "", hushed.length ? `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` : ""].filter(Boolean).join("; ") } : {}) });
|
|
5085
5236
|
}
|
|
5237
|
+
case "native_hook_launch": {
|
|
5238
|
+
if (c.role !== "console" || typeof msg.peer !== "string" || !definitionOf(msg.peer)?.hooks?.factsHook) return void reply({ ok: false, error: "managed native launch requires the person and a supported hook adapter" });
|
|
5239
|
+
if (msg.op === "end") return void reply({ ok: nativeHookLaunches.end(msg.peer, msg.launchId) });
|
|
5240
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding native launches" });
|
|
5241
|
+
if (!membershipAllowed(msg.peer)) return void reply({ ok: false, error: "peer is off roster" });
|
|
5242
|
+
if (msg.op !== "begin") return void reply({ ok: false, error: "unknown native launch operation" });
|
|
5243
|
+
const owner = bus.peers.get(msg.peer);
|
|
5244
|
+
if (owner && owner.state !== "offline") return void reply({ ok: false, error: "native peer is already attached; end it in its terminal first" });
|
|
5245
|
+
try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock).id, instanceId }); }
|
|
5246
|
+
catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
|
|
5247
|
+
}
|
|
5248
|
+
case "native_facts": {
|
|
5249
|
+
const peer = c.peer, port = peer ? definitionOf(peer)?.hooks?.factsHook : undefined;
|
|
5250
|
+
if (!peer || c.role !== "tools" || !port || msg.nativeInstanceId !== instanceId) return void reply({ ok: false });
|
|
5251
|
+
const launch = nativeHookLaunches.get(peer, msg.nativeLaunchId);
|
|
5252
|
+
const native = typeof msg.event === "string" ? port.decode(msg.event, msg.payload) : undefined;
|
|
5253
|
+
if (!launch || !native || !native.workspacePaths.every(root => { try { return realPath(root) === realPath(opts.cwd); } catch { return false; } })) return void reply({ ok: false });
|
|
5254
|
+
if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
|
|
5255
|
+
launch.session = native.sessionId;
|
|
5256
|
+
const owner = bus.peers.get(peer);
|
|
5257
|
+
if (!(owner instanceof WsPeer) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
|
|
5258
|
+
if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
|
|
5259
|
+
launch.generation = owner.sessionGeneration;
|
|
5260
|
+
const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
|
|
5261
|
+
// Preserve the start baseline at observation, even if native output is flushed before recall allows attach.
|
|
5262
|
+
const readback = port.read(native.sessionId, native.transcriptPath);
|
|
5263
|
+
const apply = () => {
|
|
5264
|
+
if (!current()) return void reply({ ok: false });
|
|
5265
|
+
if (factsOn()) { factSession(peer, native.sessionId); for (const id of readback.factIds) acked(peer, id, "native-hook"); }
|
|
5266
|
+
if (native.event === "PreInvocation" || native.event === "PreToolUse") {
|
|
5267
|
+
if (!launch.active || native.event === "PreInvocation" && native.invocation === 0) { launch.active = true; launch.turn = crypto.randomUUID(); launch.baseline = readback.completion?.index ?? -1; }
|
|
5268
|
+
activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
|
|
5269
|
+
}
|
|
5270
|
+
if (native.event === "Stop") {
|
|
5271
|
+
if (!launch.active || !native.fullyIdle || native.terminationReason !== "NO_TOOL_CALL") return void reply({ ok: false });
|
|
5272
|
+
// Native output can be flushed after its Stop command returns. Acknowledge observation, then wait for
|
|
5273
|
+
// a fresh completion row while the same launcher/session/socket generation still owns the turn.
|
|
5274
|
+
const turn = launch.turn, baseline = launch.baseline ?? -1;
|
|
5275
|
+
reply({ ok: true, pending: true });
|
|
5276
|
+
void (async () => {
|
|
5277
|
+
const deadline = Date.now() + 2000;
|
|
5278
|
+
let delay = 5;
|
|
5279
|
+
// ponytail: an unverified Stop may re-read up to 4 MiB per poll within this 2 s wait.
|
|
5280
|
+
// Upgrade: cache unchanged transcript evidence within the wait.
|
|
5281
|
+
while (current() && launch.active && launch.turn === turn) {
|
|
5282
|
+
const completion = port.read(native.sessionId, native.transcriptPath).completion;
|
|
5283
|
+
if (completion && completion.index > baseline && completion.id !== launch.completed) {
|
|
5284
|
+
launch.completed = completion.id; launch.active = false;
|
|
5285
|
+
turnEnded.set(peer, Date.now()); nativeCompletedAt.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
|
|
5286
|
+
event({ type: "native_turn_end", peer, id: completion.id }); owner.nativeStopped(native.sessionId); return;
|
|
5287
|
+
}
|
|
5288
|
+
if (Date.now() >= deadline) { notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
|
|
5289
|
+
await Bun.sleep(delay); delay = Math.min(50, delay * 2);
|
|
5290
|
+
}
|
|
5291
|
+
})().catch(() => log("managed native completion unavailable"));
|
|
5292
|
+
return;
|
|
5293
|
+
}
|
|
5294
|
+
const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
|
|
5295
|
+
return void reply({ ok: true, ...(offered ? { text: offered.text } : {}) });
|
|
5296
|
+
};
|
|
5297
|
+
if (owner.claiming) {
|
|
5298
|
+
void owner.waitForAttach(launch.generation).then(attached => {
|
|
5299
|
+
if (attached) apply(); else reply({ ok: false });
|
|
5300
|
+
}).catch(() => { log("managed native observation unavailable"); reply({ ok: false }); });
|
|
5301
|
+
return;
|
|
5302
|
+
}
|
|
5303
|
+
return apply();
|
|
5304
|
+
}
|
|
5086
5305
|
case "facts": {
|
|
5087
5306
|
// Turn-free facts (issue #108) for Claude's hooks: `pre` before a tool runs (the fact rides back as additional
|
|
5088
5307
|
// context), `post` after it (its effect, and the readback of what went in before), `stop` when its turn ends.
|
|
5089
5308
|
// The hook never shows an error: on any failure the answer is just empty.
|
|
5090
5309
|
if (!c.peer || c.role === "console") return void reply({ t: "facts", ok: false, error: "facts are for a peer" });
|
|
5310
|
+
if (definitionOf(c.peer)?.hooks?.factsHook) return void reply({ t: "facts", ok: false, error: "managed native hooks require native_facts" });
|
|
5091
5311
|
const phase: "session" | "start" | "pre" | "post" | "stop" = ["session", "start", "post", "stop"].includes(msg.phase) ? msg.phase : "pre";
|
|
5092
5312
|
const tool = typeof msg.tool === "string" ? msg.tool.slice(0, 64) : "";
|
|
5093
5313
|
const input = msg.input && typeof msg.input === "object" && !Array.isArray(msg.input) ? msg.input : {};
|
|
@@ -5362,7 +5582,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5362
5582
|
case "peer_start":
|
|
5363
5583
|
// #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
|
|
5364
5584
|
if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
|
|
5365
|
-
void hubStart(msg.peer, "the console").then(reply, (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
|
|
5585
|
+
void hubStart(msg.peer, "the console").then(result => reply(includeRetainedLaunchConfig(String(msg.peer), result)), (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
|
|
5366
5586
|
return;
|
|
5367
5587
|
case "peer_stop":
|
|
5368
5588
|
if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
|
|
@@ -5376,7 +5596,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5376
5596
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
5377
5597
|
.then((r) => {
|
|
5378
5598
|
if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
|
|
5379
|
-
reply({ t: "started", ...r });
|
|
5599
|
+
reply({ t: "started", ...includeRetainedLaunchConfig(String(msg.peer), r) });
|
|
5380
5600
|
});
|
|
5381
5601
|
return;
|
|
5382
5602
|
case "task":
|