@staix/agent-hub 0.12.35 → 0.12.37
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 +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
|
|
1
2
|
import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
|
|
2
3
|
import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
|
|
3
4
|
import { isDenied } from "../local/deny.ts";
|
|
@@ -14,17 +15,19 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
|
14
15
|
import { loadPetConfig } from "../pet/config.ts";
|
|
15
16
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
16
17
|
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
18
|
+
import { buildReviewBrief } from "./review-brief.ts";
|
|
17
19
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
18
20
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
19
21
|
import { ActivityRing } from "./activity.ts";
|
|
20
22
|
import { JcodePeer } from "../adapters/jcode.ts";
|
|
21
23
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
22
|
-
import { loadSdlc, sdlcGh } from "./sdlc.ts";
|
|
24
|
+
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
25
|
+
import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
23
26
|
import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
|
|
24
27
|
import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
|
|
25
28
|
import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
26
29
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
27
|
-
import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
30
|
+
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
|
|
28
31
|
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";
|
|
29
32
|
import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
30
33
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
@@ -46,7 +49,7 @@ import { resolveSettledRoles } from "./settled-roles.ts";
|
|
|
46
49
|
import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
|
|
47
50
|
import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
|
|
48
51
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
49
|
-
import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
52
|
+
import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
|
|
50
53
|
import type { ServerWebSocket } from "bun";
|
|
51
54
|
import { AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
|
|
52
55
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -61,6 +64,9 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
61
64
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
62
65
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
63
66
|
import { LocalPeer } from "../adapters/local-worker.ts";
|
|
67
|
+
import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
68
|
+
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
69
|
+
import { reviewDiff } from "../adapters/claude-print.ts";
|
|
64
70
|
import { Capture, skipTools } from "../memory/capture.ts";
|
|
65
71
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
66
72
|
import { Sidecar } from "../switchyard/sidecar.ts";
|
|
@@ -69,12 +75,12 @@ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type
|
|
|
69
75
|
import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
70
76
|
import { HUB } from "./envelope.ts";
|
|
71
77
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
72
|
-
import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
|
|
78
|
+
import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
|
|
73
79
|
import { basename, isAbsolute } from "node:path";
|
|
74
80
|
import { fileURLToPath } from "node:url";
|
|
75
81
|
import { realPath, hubHome } from "./project.ts";
|
|
76
82
|
import type { BusEvent } from "./bus.ts";
|
|
77
|
-
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
83
|
+
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
78
84
|
import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
|
|
79
85
|
import { SupervisionFeed } from "./supervision.ts";
|
|
80
86
|
import { drainCliAudits } from "../cli/identity-audit.ts";
|
|
@@ -115,10 +121,10 @@ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type Contex
|
|
|
115
121
|
import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
|
|
116
122
|
import { detectTooling } from "./tooling.ts";
|
|
117
123
|
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";
|
|
118
|
-
import {
|
|
124
|
+
import { memoryProjectKey } from "@staix/agent-mem";
|
|
125
|
+
import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
119
126
|
import { syncMemory } from "@staix/agent-mem";
|
|
120
127
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
|
|
121
|
-
import { memoryProjectKey } from "@staix/agent-mem";
|
|
122
128
|
import { nativeRecall } from "../memory/recall.ts";
|
|
123
129
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
124
130
|
|
|
@@ -181,7 +187,7 @@ export interface HubConfig {
|
|
|
181
187
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
182
188
|
limits: LimitsConfig;
|
|
183
189
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
184
|
-
review: { adaptive: boolean; min_reviews: number };
|
|
190
|
+
review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
|
|
185
191
|
/** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
|
|
186
192
|
recovery: { auto_resume_after_crash: boolean };
|
|
187
193
|
/** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
|
|
@@ -435,6 +441,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
|
|
|
435
441
|
}
|
|
436
442
|
|
|
437
443
|
export interface DaemonOptions {
|
|
444
|
+
/** Host-only admission transport; never selected by project config or tools. */
|
|
445
|
+
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
438
446
|
projectId?: string;
|
|
439
447
|
instanceId?: string;
|
|
440
448
|
cwd: string;
|
|
@@ -598,20 +606,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
598
606
|
let ready = false;
|
|
599
607
|
try {
|
|
600
608
|
const loadedConfig = opts.config ?? loadConfig(opts.cwd);
|
|
609
|
+
reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
|
|
601
610
|
const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
|
|
602
611
|
// Preserve embedding-owned policy references, but never publish reloads into shared defaults.
|
|
603
612
|
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;
|
|
604
613
|
const declarations = validateInstances(loadedConfig.instances);
|
|
605
614
|
const extraIds = Object.keys(declarations);
|
|
615
|
+
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
616
|
+
const channelExtras = extraIds.filter(id => instanceDefinitionOf(id, declarations)?.identity.mcp === "channel-plugin");
|
|
617
|
+
// Every Claude-family peer whose own state files the daemon reads: the builtin channel kinds, then their declared instances.
|
|
618
|
+
const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
|
|
606
619
|
const executionRoots = new Map<string, string>();
|
|
607
620
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
|
|
621
|
+
const reviewerUnavailable = new Map<string, string>();
|
|
608
622
|
const roles = { ...loadedConfig.roles };
|
|
609
623
|
const permissionModesFromInstances: Record<string, PermissionMode> = {};
|
|
610
624
|
const startModesFromInstances: PeerStartConfig = {};
|
|
611
625
|
const instancePermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
|
|
612
626
|
for (const [id, declaration] of Object.entries(declarations)) {
|
|
613
627
|
roles[id] = effectiveInstanceRoles(id, declarations, loadedConfig.roles);
|
|
614
|
-
startModesFromInstances[id] = { start_mode: "headless" };
|
|
628
|
+
if (!channelExtras.includes(id)) startModesFromInstances[id] = { start_mode: "headless" };
|
|
615
629
|
if (declaration.permission_mode !== undefined) {
|
|
616
630
|
permissionModesFromInstances[id] = declaration.permission_mode;
|
|
617
631
|
instancePermissionSources[id] = { mode: declaration.permission_mode, source: ".agenthub/config.local.json" };
|
|
@@ -627,10 +641,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
627
641
|
const definitionOf = (peer: string) => instanceDefinitionOf(peer, declarations);
|
|
628
642
|
const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
|
|
629
643
|
const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
|
|
630
|
-
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds];
|
|
644
|
+
const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
|
|
631
645
|
const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
|
|
632
|
-
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer)?.start.conductorStartable === true;
|
|
633
|
-
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds]);
|
|
646
|
+
const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
|
|
647
|
+
const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
|
|
648
|
+
// #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
|
|
649
|
+
const reviewOnlyPeer = (peer: string): boolean => Object.hasOwn(declarations, peer) && config.roles[peer]?.length === 1 && config.roles[peer]![0] === "reviewer";
|
|
650
|
+
/** #520: a task under review shows its review brief to whoever may read it (owner, reviewer, conductor). */
|
|
651
|
+
const briefField = (task: Task): { brief?: string } => { if (task.state !== "in_review") return {}; const text = tasks.briefText(task); return text ? { brief: text } : {}; };
|
|
652
|
+
/** #223 R2: a review-only instance reads the spec, refs and earlier verdicts of a task, never the builder's narrative. */
|
|
653
|
+
const reviewerView = (peer: string, view: Record<string, unknown>): Record<string, unknown> => !reviewOnlyPeer(peer) || !Array.isArray(view.history) ? view
|
|
654
|
+
: { ...view, history: (view.history as { event?: unknown; note?: unknown }[]).map(({ note, ...h }) => h.event === "approved" || h.event === "changes_requested" ? { ...h, note } : h) };
|
|
655
|
+
const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
|
|
634
656
|
const SETTINGS = projectSettings(declarations);
|
|
635
657
|
const settingDef = (key: unknown) => instanceSettingDef(key, declarations);
|
|
636
658
|
const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
|
|
@@ -872,6 +894,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
872
894
|
* has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
|
|
873
895
|
*/
|
|
874
896
|
const silence = (env: Envelope, to: PeerId): string | undefined => {
|
|
897
|
+
if (reviewOnlyPeer(to) && env.to?.includes(to) && !reviewOnlyItem(to, env)) return `${to} is a review-only instance: it receives review requests only (send a review through the task board)`;
|
|
875
898
|
if (!turnFree() || env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi" || env.kind !== "chat" || to === USER) return undefined;
|
|
876
899
|
const cohort = tasks.silenced(env.from, to);
|
|
877
900
|
if (!cohort) return undefined;
|
|
@@ -914,7 +937,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
914
937
|
const staleOff = () => config.experiments?.stale_notices === "deliver";
|
|
915
938
|
if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
|
|
916
939
|
const requestedPeerStops = new Set<string>();
|
|
917
|
-
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
940
|
+
const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
|
|
918
941
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
|
|
919
942
|
startupCleanup.push(() => bus.closeJournal());
|
|
920
943
|
const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
|
|
@@ -961,7 +984,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
961
984
|
const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
|
|
962
985
|
// #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
|
|
963
986
|
// normalized origin remote, the same on every machine; without one, its path). Fail-open: without it the hub works as before.
|
|
964
|
-
|
|
987
|
+
// Daemon start never depends on the memory home: an unresolvable home falls back to the raw key.
|
|
988
|
+
const memoryProjectRaw = memoryProjectKey(opts.cwd);
|
|
989
|
+
const memoryProject = (() => { try { return memoryProjectFor(opts.cwd); } catch { return memoryProjectRaw; } })();
|
|
965
990
|
let memoryStore: MemoryStore | undefined;
|
|
966
991
|
let memoryBinding: MemoryProfileBinding | undefined;
|
|
967
992
|
let memoryProblem: string | undefined;
|
|
@@ -973,11 +998,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
973
998
|
memoryNotice(`hub native memory disabled: ${reason}`);
|
|
974
999
|
};
|
|
975
1000
|
if (config.memory.native) {
|
|
976
|
-
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) => {
|
|
1001
|
+
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) => {
|
|
977
1002
|
const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
|
|
978
1003
|
return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
|
|
979
1004
|
}) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
|
|
980
|
-
catch { if (!memoryProblem) memoryUnavailable("memory profile unavailable; inspect the selector and generation, then restart the hub"); }
|
|
1005
|
+
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"); }
|
|
981
1006
|
}
|
|
982
1007
|
const memoryProfileStatus = (person = false) => {
|
|
983
1008
|
const value = memoryBinding?.status(memoryStore) ?? ({
|
|
@@ -1080,7 +1105,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1080
1105
|
|
|
1081
1106
|
/** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
|
|
1082
1107
|
async function ensurePreface(peer: PeerId): Promise<void> {
|
|
1083
|
-
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer)) return;
|
|
1108
|
+
if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
|
|
1084
1109
|
// The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
|
|
1085
1110
|
let own: string | undefined;
|
|
1086
1111
|
try {
|
|
@@ -1276,9 +1301,55 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1276
1301
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
1277
1302
|
}));
|
|
1278
1303
|
};
|
|
1304
|
+
const releaseQuotaSnapshot = () => {
|
|
1305
|
+
const headroom = budget.headroom(), rows = [...bus.peers].filter(([, peer]) => peer.state !== "offline");
|
|
1306
|
+
const reasons: string[] = [], notes: string[] = [];
|
|
1307
|
+
for (const [id] of rows) {
|
|
1308
|
+
const definition = definitionOf(id), account = definition?.budget?.account ?? id;
|
|
1309
|
+
if (manualPaused.has(id)) reasons.push(`${id}: manual pause; ahub resume ${id}`);
|
|
1310
|
+
if (budget.record(account)) reasons.push(`${id}: paused quota window; inspect ahub budget or ahub budget resume ${id}`);
|
|
1311
|
+
if (definition && (!definition.budget || definition.budget.quotaExempt)) continue;
|
|
1312
|
+
// Source availability belongs to the shared account, including its offline base and reviewer variants.
|
|
1313
|
+
// The soft token window uses the same definition capability that resets it on resume.
|
|
1314
|
+
const hasSource = accountMembers(account).some(member => {
|
|
1315
|
+
const policy = definitionOf(member)?.budget;
|
|
1316
|
+
return !!policy?.reader || !!policy?.resetSoftTokensOnResume && config.budget.kimi_tokens_5h > 0;
|
|
1317
|
+
});
|
|
1318
|
+
if (definition && !hasSource) {
|
|
1319
|
+
notes.push(`${id}: no quota source`);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const reading = headroom[account];
|
|
1323
|
+
if (!reading || !Number.isFinite(reading.headroom)) reasons.push(`${id}: quota window unknown or stale; inspect ahub budget or ahub budget set ${id} <0..1>`);
|
|
1324
|
+
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>`);
|
|
1325
|
+
}
|
|
1326
|
+
const idle = rows.every(([, peer]) => peer.state === "idle");
|
|
1327
|
+
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]) }) };
|
|
1328
|
+
};
|
|
1329
|
+
const releaseWindow = async () => {
|
|
1330
|
+
const host = await (opts.releaseHostAdmission ?? readReleaseHostAdmission)(opts.cwd), quota = releaseQuotaSnapshot();
|
|
1331
|
+
if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
|
|
1332
|
+
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 };
|
|
1333
|
+
};
|
|
1279
1334
|
const tasks: Tasks = new Tasks({
|
|
1335
|
+
releaseWindow,
|
|
1280
1336
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
1337
|
+
plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
|
|
1338
|
+
const codes: ("approvals" | "quota")[] = [];
|
|
1339
|
+
if ([...permissions.values()].some(request => request.peer === peer && request.expiresAt > Date.now())) codes.push("approvals");
|
|
1340
|
+
if (budget.record(peer)) codes.push("quota");
|
|
1341
|
+
return [peer, codes];
|
|
1342
|
+
})),
|
|
1281
1343
|
instances: () => declarations,
|
|
1344
|
+
reviewOnly: reviewOnlyPeer,
|
|
1345
|
+
// #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
|
|
1346
|
+
reviewAuthority: (by, task) => {
|
|
1347
|
+
if (!definitionOf(by)?.reviewer) return undefined;
|
|
1348
|
+
const peer = bus.peers.get(by) as ReviewerPrintPeer | undefined, token = peer?.reviewToken(task);
|
|
1349
|
+
return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
|
|
1350
|
+
},
|
|
1351
|
+
// #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
|
|
1352
|
+
reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
|
|
1282
1353
|
// Proof belongs to the adapter generation actually started in the validated root. A role-only
|
|
1283
1354
|
// reload cannot turn a canonical reviewer process into an isolated implementation owner.
|
|
1284
1355
|
ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
|
|
@@ -1294,6 +1365,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1294
1365
|
catalog,
|
|
1295
1366
|
sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
|
|
1296
1367
|
projectedReview: task => reviewProjection(task),
|
|
1368
|
+
reviewBrief: task => {
|
|
1369
|
+
const root = executionRoot(task.owner ?? "");
|
|
1370
|
+
return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1371
|
+
},
|
|
1297
1372
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1298
1373
|
board,
|
|
1299
1374
|
bus,
|
|
@@ -1368,7 +1443,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1368
1443
|
recordCohort: (c) => event({ type: "cohort", ...c }),
|
|
1369
1444
|
// The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
|
|
1370
1445
|
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) }),
|
|
1371
|
-
failing: () => bus.failingPeers(),
|
|
1446
|
+
failing: () => ({ ...bus.failingPeers(), ...Object.fromEntries(reviewerUnavailable) }),
|
|
1372
1447
|
held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
|
|
1373
1448
|
});
|
|
1374
1449
|
relevantNotice = (peer, env) => {
|
|
@@ -1547,8 +1622,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1547
1622
|
} catch { /* no transcript yet: no readback */ }
|
|
1548
1623
|
};
|
|
1549
1624
|
/** Claude's transcript for this session: the one the status line reported, or the hook's own, inside Claude's projects. */
|
|
1550
|
-
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown): string | undefined => {
|
|
1551
|
-
const reported = claudeSession();
|
|
1625
|
+
const claudeTranscript = (sessionId: string | undefined, fromHook: unknown, peer = "claude"): string | undefined => {
|
|
1626
|
+
const reported = claudeSession(peer);
|
|
1552
1627
|
if (reported.transcriptPath && (!sessionId || reported.sessionId === sessionId)) return reported.transcriptPath;
|
|
1553
1628
|
if (!sessionId || typeof fromHook !== "string" || !isAbsolute(fromHook) || basename(fromHook) !== `${sessionId}.jsonl`) return undefined;
|
|
1554
1629
|
try {
|
|
@@ -1572,6 +1647,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1572
1647
|
const activeAt = new Map<PeerId, number>();
|
|
1573
1648
|
interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
|
|
1574
1649
|
const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
|
|
1650
|
+
const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
|
|
1575
1651
|
const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
|
|
1576
1652
|
/** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
|
|
1577
1653
|
const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
|
|
@@ -1615,7 +1691,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1615
1691
|
const contextSession = (peer: PeerId): string | undefined => {
|
|
1616
1692
|
const p = bus.peers.get(peer);
|
|
1617
1693
|
if (p instanceof WsPeer && p.claiming) return undefined;
|
|
1618
|
-
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1694
|
+
if (definitionOf(peer)?.turnEnd === "native-hook") { const c = claudeSession(peer); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
1619
1695
|
if (p instanceof CodexPeer) return p.thread || undefined;
|
|
1620
1696
|
if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
1621
1697
|
return undefined;
|
|
@@ -1829,7 +1905,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1829
1905
|
};
|
|
1830
1906
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
1831
1907
|
let claudeUsageSeen = 0;
|
|
1832
|
-
|
|
1908
|
+
const claudeContextSeen = new Map<string, string>();
|
|
1833
1909
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1834
1910
|
const intervals = [
|
|
1835
1911
|
setInterval(() => {
|
|
@@ -1861,16 +1937,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1861
1937
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
|
|
1862
1938
|
}
|
|
1863
1939
|
}
|
|
1864
|
-
try {
|
|
1865
|
-
const file = join(opts.stateDir, "claude-context
|
|
1940
|
+
for (const claudePeer of channelPeers) try {
|
|
1941
|
+
const file = join(opts.stateDir, claudeStateFile("claude-context", claudePeer));
|
|
1866
1942
|
const payload = readFileSync(file, "utf8");
|
|
1867
|
-
if (payload === claudeContextSeen)
|
|
1943
|
+
if (payload === claudeContextSeen.get(claudePeer)) continue;
|
|
1868
1944
|
const c = JSON.parse(payload);
|
|
1869
|
-
const session = claudeSession();
|
|
1870
|
-
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(
|
|
1871
|
-
claudeContextSeen
|
|
1872
|
-
const bound = contextSession(
|
|
1873
|
-
if (bound) contexts.report(
|
|
1945
|
+
const session = claudeSession(claudePeer);
|
|
1946
|
+
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf(claudePeer))) continue;
|
|
1947
|
+
claudeContextSeen.set(claudePeer, payload);
|
|
1948
|
+
const bound = contextSession(claudePeer);
|
|
1949
|
+
if (bound) contexts.report(claudePeer, claudeContext(c.context, bound, c.at), bound);
|
|
1874
1950
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
1875
1951
|
}, 1000),
|
|
1876
1952
|
setInterval(() => budget.tick(), 30_000),
|
|
@@ -1925,7 +2001,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1925
2001
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
1926
2002
|
// What the hub's MCP server offers a native peer. Codex approves them in config, Kimi's requests for exactly these
|
|
1927
2003
|
// names are answered by the hub; every call still passes daemon role/capability checks before its effects.
|
|
1928
|
-
const HUB_TOOLS = ["hub_send", ...
|
|
2004
|
+
const HUB_TOOLS = ["hub_send", ...TASK_TOOL_NAMES, ...CONDUCTOR_TOOLS.map((t) => t.name)];
|
|
1929
2005
|
const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
|
|
1930
2006
|
const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
1931
2007
|
|
|
@@ -1980,11 +2056,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1980
2056
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
1981
2057
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
1982
2058
|
if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
|
|
1983
|
-
|
|
2059
|
+
const result = await conductor.execute(by, op, a);
|
|
2060
|
+
const shown = op === "hub_task_show" && result && typeof result === "object" ? board.get(Number(a.id)) : undefined;
|
|
2061
|
+
return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
|
|
1984
2062
|
}
|
|
1985
2063
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
1986
2064
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
1987
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || 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`);
|
|
2065
|
+
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`);
|
|
1988
2066
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
1989
2067
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
1990
2068
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
@@ -2098,8 +2176,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2098
2176
|
}
|
|
2099
2177
|
case "hub_backlog_list":
|
|
2100
2178
|
return JSON.stringify(tasks.backlogList(by));
|
|
2179
|
+
case "hub_release_new":
|
|
2180
|
+
return JSON.stringify(tasks.publicRelease(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order })));
|
|
2181
|
+
case "hub_release_report":
|
|
2182
|
+
return JSON.stringify(tasks.releaseReport(by, a.id));
|
|
2183
|
+
case "hub_release_configure":
|
|
2184
|
+
return JSON.stringify(tasks.publicRelease(await tasks.configureRelease(by, { id: a.id, revision: a.revision, hotfix: a.hotfix, state: a.state, cut: a.cut })));
|
|
2185
|
+
case "hub_release_assign":
|
|
2186
|
+
return JSON.stringify(tasks.publicView(await tasks.assignRelease(by, { id: a.id, revision: a.revision, release: a.release, reason: a.reason })));
|
|
2187
|
+
case "hub_release_cut":
|
|
2188
|
+
return JSON.stringify(tasks.publicRelease(await tasks.cutRelease(by, { id: a.id, revision: a.revision })));
|
|
2101
2189
|
case "hub_release_list":
|
|
2102
2190
|
return JSON.stringify(tasks.releaseList(by));
|
|
2191
|
+
case "hub_planner_groom":
|
|
2192
|
+
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2193
|
+
case "hub_sdlc_costs":
|
|
2194
|
+
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2195
|
+
case "hub_plan_review": {
|
|
2196
|
+
const { id, ...review } = a;
|
|
2197
|
+
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
2198
|
+
}
|
|
2199
|
+
case "hub_backlog_return_untouched":
|
|
2200
|
+
return JSON.stringify(tasks.publicView(await tasks.returnUntouchedToBacklog(by, { id: a.id, revision: a.revision, reason: a.reason })));
|
|
2103
2201
|
case "hub_backlog_plan":
|
|
2104
2202
|
return JSON.stringify(tasks.publicView(await tasks.planBacklog(by, { id: a.id, revision: a.revision, fields: a.fields, reason: a.reason })));
|
|
2105
2203
|
case "hub_backlog_pull":
|
|
@@ -2120,7 +2218,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2120
2218
|
return [line(t), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2121
2219
|
}
|
|
2122
2220
|
case "hub_task_accept": {
|
|
2123
|
-
|
|
2221
|
+
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
2222
|
+
const t = await tasks.acceptReviewed(by, a.id, a.plan);
|
|
2124
2223
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
2125
2224
|
const silent = tasks.silentFor(t.id);
|
|
2126
2225
|
const overlap = a.plan == null && !silent ? "" : tasks.overlaps(t);
|
|
@@ -2151,9 +2250,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2151
2250
|
return extra ? `${result}\n${extra}` : result;
|
|
2152
2251
|
}
|
|
2153
2252
|
case "hub_sdlc_record":
|
|
2253
|
+
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
2154
2254
|
return line(tasks.recordLifecycle(by, a.id, a.receipt));
|
|
2155
2255
|
case "hub_review":
|
|
2156
|
-
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head));
|
|
2256
|
+
return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
|
|
2157
2257
|
case "hub_remember":
|
|
2158
2258
|
return tasks.remember(by, a);
|
|
2159
2259
|
case "hub_checkpoint": {
|
|
@@ -2193,7 +2293,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2193
2293
|
if (op === "task_show" && by !== USER) {
|
|
2194
2294
|
const task = board.get(Number(a.id));
|
|
2195
2295
|
if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
2196
|
-
|
|
2296
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2297
|
+
return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
|
|
2197
2298
|
}
|
|
2198
2299
|
if (op === "route_explain" && by !== USER) {
|
|
2199
2300
|
if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
|
|
@@ -2220,6 +2321,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2220
2321
|
case "backlog_pull":
|
|
2221
2322
|
case "backlog_return":
|
|
2222
2323
|
return line(await tasks.moveBacklog(by, a.id, op === "backlog_pull"));
|
|
2324
|
+
case "review_brief": {
|
|
2325
|
+
const task = board.get(Number(a.id));
|
|
2326
|
+
return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
|
|
2327
|
+
}
|
|
2223
2328
|
case "task_show": {
|
|
2224
2329
|
const task = board.get(Number(a.id));
|
|
2225
2330
|
return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
|
|
@@ -2349,19 +2454,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2349
2454
|
// #64: a Claude session that never persisted a transcript cannot be resumed, so the
|
|
2350
2455
|
// restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
|
|
2351
2456
|
// written before this flag existed are re-derived from disk.
|
|
2352
|
-
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
|
|
2457
|
+
const unpersistedClaude = definitionOf(saved.id)?.session.missingEvidence === "transcript" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.id, saved.sessionId)));
|
|
2353
2458
|
if (!unpersistedClaude && !waived()) return false;
|
|
2354
2459
|
}
|
|
2355
2460
|
return true;
|
|
2356
2461
|
});
|
|
2357
2462
|
};
|
|
2358
|
-
|
|
2463
|
+
/** #450 I3: each Claude peer (claude and every claude-<name> instance) has its own session and launch files. */
|
|
2464
|
+
const claudeSession = (peer = "claude"): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
2359
2465
|
try {
|
|
2360
|
-
const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session
|
|
2466
|
+
const value = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-session", peer)), "utf8"));
|
|
2361
2467
|
if (value.instanceId !== instanceId) return {};
|
|
2362
2468
|
let currentLauncher = false;
|
|
2363
2469
|
try {
|
|
2364
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
2470
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
2365
2471
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
|
|
2366
2472
|
currentLauncher = true;
|
|
2367
2473
|
if (value.launchId !== launch.launchId) return {};
|
|
@@ -2369,7 +2475,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2369
2475
|
} catch { /* older/manual launches retain the existing instance fence */ }
|
|
2370
2476
|
try {
|
|
2371
2477
|
const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
2372
|
-
const current =
|
|
2478
|
+
const current = ownTerminalRow(records, peer, opts.cwd, instanceId);
|
|
2373
2479
|
if (!currentLauncher && current?.launchId && value.launchId !== current.launchId) return {};
|
|
2374
2480
|
} catch { /* no managed terminal record: the instance fence is still enforced */ }
|
|
2375
2481
|
return {
|
|
@@ -2427,33 +2533,30 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2427
2533
|
}
|
|
2428
2534
|
/** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
|
|
2429
2535
|
* path Claude itself reported through the status line; fall back to the slug computation. */
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
2434
|
-
return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
|
|
2435
|
-
};
|
|
2536
|
+
// ponytail: the version probe reads the builtin claude peer only; extend per peer if an instance's version matters (#450 I3).
|
|
2537
|
+
const claudeTranscriptPersisted = (peer: string, sessionId: string): boolean => transcriptPersisted(claudeSession(peer), sessionId, Object.hasOwn(declarations, peer),
|
|
2538
|
+
join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
|
|
2436
2539
|
// Optional native usage: only the instance-fenced explicit Claude session is read.
|
|
2437
2540
|
// IDs persist in telemetry, so a restart/resume never counts the same native message twice.
|
|
2438
2541
|
const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
|
|
2439
|
-
const collectClaudeUsage = () => {
|
|
2440
|
-
const session = claudeSession();
|
|
2441
|
-
if (!session.sessionId || !session.transcriptPath)
|
|
2542
|
+
const collectClaudeUsage = () => { for (const peer of channelPeers) {
|
|
2543
|
+
const session = claudeSession(peer);
|
|
2544
|
+
if (!session.sessionId || !session.transcriptPath) continue;
|
|
2442
2545
|
for (const record of readClaudeTranscriptUsage(session.sessionId, session.transcriptPath)) {
|
|
2443
2546
|
if (claudeNativeUsageSeen.has(record.id)) continue;
|
|
2444
2547
|
claudeNativeUsageSeen.add(record.id);
|
|
2445
2548
|
const reported = claudeReportedTokens(record.usage);
|
|
2446
|
-
if (reported !== undefined) addTokens(
|
|
2447
|
-
event({ type: "usage", peer
|
|
2549
|
+
if (reported !== undefined) addTokens(peer, reported);
|
|
2550
|
+
event({ type: "usage", peer, source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution(peer), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
2448
2551
|
}
|
|
2449
|
-
};
|
|
2552
|
+
} };
|
|
2450
2553
|
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
2451
2554
|
claudeUsageTimer.unref(); intervals.push(claudeUsageTimer);
|
|
2452
2555
|
const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
|
|
2453
2556
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
2454
2557
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
2455
2558
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
2456
|
-
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession().sessionId : metadata.sessionId;
|
|
2559
|
+
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
2457
2560
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
2458
2561
|
return [id, row];
|
|
2459
2562
|
}));
|
|
@@ -2496,7 +2599,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2496
2599
|
if (peer instanceof AcpPeer && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
|
|
2497
2600
|
if (starting.has(id) && (peer instanceof AcpPeer || peer instanceof CodexPeer)) return peer.getPermissionMode();
|
|
2498
2601
|
if (definitionOf(id)?.transport === "claude-channel" && peer instanceof WsPeer && peer.state !== "offline") {
|
|
2499
|
-
const launch = claudePermissionLaunch();
|
|
2602
|
+
const launch = claudePermissionLaunch(id);
|
|
2500
2603
|
if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
|
|
2501
2604
|
}
|
|
2502
2605
|
return permissionMode(id);
|
|
@@ -2508,6 +2611,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2508
2611
|
return mode === "ask" ? {} : { permissionMode: mode };
|
|
2509
2612
|
};
|
|
2510
2613
|
const lastCodexThreads = new Map<string, string>();
|
|
2614
|
+
// #450 I3: the console and dashboard list each declared instance right after its kind.
|
|
2615
|
+
const byKind = (ids: string[]): string[] => {
|
|
2616
|
+
const kindOf = (id: string) => Object.hasOwn(declarations, id) ? declarations[id]!.kind : undefined;
|
|
2617
|
+
const out = ids.filter(id => kindOf(id) === undefined).flatMap(id => [id, ...ids.filter(extra => kindOf(extra) === id)]);
|
|
2618
|
+
return [...out, ...ids.filter(id => !out.includes(id))];
|
|
2619
|
+
};
|
|
2511
2620
|
const statusPeerIds = (open: ReturnType<Board["statusTasks"]>) => {
|
|
2512
2621
|
const reservations = board.statusReservations();
|
|
2513
2622
|
const ids = new Set([...bus.knownPeers(), ...extraIds, ...reservations]);
|
|
@@ -2519,12 +2628,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2519
2628
|
const taskNeeds = board.statusTasks();
|
|
2520
2629
|
const taskStalls = tasks.stalled();
|
|
2521
2630
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
2522
|
-
const peerIds = statusPeerIds(taskNeeds);
|
|
2631
|
+
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
2523
2632
|
const view = {
|
|
2524
2633
|
...(crashReport.length ? { crash: crashReport } : {}),
|
|
2525
2634
|
projectId,
|
|
2526
2635
|
instanceId,
|
|
2527
2636
|
version: VERSION,
|
|
2637
|
+
pluginBundle: SERVER_JS,
|
|
2528
2638
|
pendingRestart,
|
|
2529
2639
|
memoryProfile: memoryProfileStatus(includeAttentionItems),
|
|
2530
2640
|
lifecycleStages: [...lifecycleConfig.stages],
|
|
@@ -2534,6 +2644,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2534
2644
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
2535
2645
|
protocol: PROTOCOL,
|
|
2536
2646
|
features: DAEMON_FEATURES,
|
|
2647
|
+
// #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
|
|
2648
|
+
activeInstances: extraIds,
|
|
2537
2649
|
stopping,
|
|
2538
2650
|
pid: process.pid,
|
|
2539
2651
|
cwd: opts.cwd,
|
|
@@ -3173,6 +3285,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3173
3285
|
return { ok: true, mode, opened: opened.via, command };
|
|
3174
3286
|
}
|
|
3175
3287
|
|
|
3288
|
+
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
3289
|
+
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
3290
|
+
|
|
3176
3291
|
async function stopPeer(peer: unknown, rosterRemoval = false): Promise<Record<string, unknown>> {
|
|
3177
3292
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
3178
3293
|
if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding peer stops; finish the operation before retrying" };
|
|
@@ -3180,9 +3295,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3180
3295
|
const owner = bus.peers.get(peer);
|
|
3181
3296
|
const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
|
|
3182
3297
|
if (stopNotice) return { ok: false, error: stopNotice };
|
|
3183
|
-
if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
|
|
3298
|
+
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" };
|
|
3184
3299
|
if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
|
|
3185
|
-
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with
|
|
3300
|
+
if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
|
|
3186
3301
|
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" };
|
|
3187
3302
|
requestedPeerStops.add(peer); // fence delivery, starts and recovery before the first asynchronous stop step
|
|
3188
3303
|
try {
|
|
@@ -3199,7 +3314,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3199
3314
|
writeStatus();
|
|
3200
3315
|
const state = bus.stateOf(peer);
|
|
3201
3316
|
if (state !== "offline" || bus.peers.get(peer) !== owner) return { ok: false, error: "peer stop did not reach offline on its owner; inspect ahub status and its terminal before retrying" };
|
|
3202
|
-
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with
|
|
3317
|
+
try { notify(rosterRemoval ? `${peer} stopped for roster removal; add it with ahub roster add ${peer} before starting it again` : `${peer} stopped by the console; start it again with ${startCommand(peer)}`); } catch { /* owner stop and readback remain authoritative */ }
|
|
3203
3318
|
return { ok: true, state };
|
|
3204
3319
|
});
|
|
3205
3320
|
} catch {
|
|
@@ -3244,6 +3359,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3244
3359
|
|
|
3245
3360
|
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>> {
|
|
3246
3361
|
const adapter = definitionOf(peer);
|
|
3362
|
+
if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
|
|
3247
3363
|
const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
|
|
3248
3364
|
if (preflight) return { ok: false, error: preflight };
|
|
3249
3365
|
if (adapter?.attach?.admission && !declarations[peer]) {
|
|
@@ -3559,7 +3675,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3559
3675
|
const result = await piReceipts!.execute(sessionId ?? "", callId, name, humanBash ? { origin: "verified-user-bash", args: raw } : raw, async () => {
|
|
3560
3676
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "error: invalid tool arguments";
|
|
3561
3677
|
const nativeTurn = pi.observationTurn;
|
|
3562
|
-
const output =
|
|
3678
|
+
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,
|
|
3563
3679
|
...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
|
|
3564
3680
|
permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
|
|
3565
3681
|
const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
|
|
@@ -3738,6 +3854,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3738
3854
|
await local.start();
|
|
3739
3855
|
return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
|
|
3740
3856
|
}
|
|
3857
|
+
if (adapter?.reviewer) {
|
|
3858
|
+
const backend = adapter.reviewer.backend;
|
|
3859
|
+
if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
|
|
3860
|
+
// #519: each backend proves isolation before admission, then starts one fresh child per review.
|
|
3861
|
+
if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
|
|
3862
|
+
if (existing) { mute(existing); await existing.stop(); }
|
|
3863
|
+
const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
|
|
3864
|
+
backend, cwd: executionCwd,
|
|
3865
|
+
mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
|
|
3866
|
+
systemPrompt: sessionRoleContract(peer),
|
|
3867
|
+
watchdogMs: config.watchdog_ms,
|
|
3868
|
+
privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
|
|
3869
|
+
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 }),
|
|
3870
|
+
verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
|
|
3871
|
+
onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
|
|
3872
|
+
onUnavailable: (failure, current) => {
|
|
3873
|
+
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
3874
|
+
if (!attached()) return;
|
|
3875
|
+
if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
|
|
3876
|
+
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
3877
|
+
},
|
|
3878
|
+
log,
|
|
3879
|
+
}) }) as ReviewerPrintPeer;
|
|
3880
|
+
try { await reviewer.start(); }
|
|
3881
|
+
catch {
|
|
3882
|
+
await reviewer.stop().catch(() => {});
|
|
3883
|
+
const reason = "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
|
|
3884
|
+
reviewerUnavailable.set(peer, reason);
|
|
3885
|
+
return { ok: false, error: reason };
|
|
3886
|
+
}
|
|
3887
|
+
reviewerUnavailable.delete(peer);
|
|
3888
|
+
executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
|
|
3889
|
+
bus.add(reviewer);
|
|
3890
|
+
return { ok: true };
|
|
3891
|
+
}
|
|
3741
3892
|
return { ok: false, error: `unknown peer "${peer}"` };
|
|
3742
3893
|
}
|
|
3743
3894
|
|
|
@@ -4601,16 +4752,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4601
4752
|
return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
|
|
4602
4753
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
4603
4754
|
};
|
|
4755
|
+
const incompatibleRecoveryTarget = () => {
|
|
4756
|
+
const cancelled = incompatibleCancellationTarget();
|
|
4757
|
+
if (cancelled) return cancelled;
|
|
4758
|
+
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";
|
|
4759
|
+
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";
|
|
4760
|
+
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";
|
|
4761
|
+
return undefined;
|
|
4762
|
+
};
|
|
4604
4763
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
4605
4764
|
if (checkTarget) {
|
|
4606
|
-
const error =
|
|
4607
|
-
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");
|
|
4765
|
+
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
4608
4766
|
}
|
|
4609
4767
|
|
|
4610
4768
|
if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
|
|
4611
4769
|
for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
|
|
4612
4770
|
try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
|
|
4613
|
-
if (checkTarget) { const error =
|
|
4771
|
+
if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4614
4772
|
}
|
|
4615
4773
|
}
|
|
4616
4774
|
if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
|
|
@@ -4626,7 +4784,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4626
4784
|
recoveryPeerSnapshot = undefined;
|
|
4627
4785
|
}
|
|
4628
4786
|
if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
|
|
4629
|
-
{ const error =
|
|
4787
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4630
4788
|
recoveryOperationId = op;
|
|
4631
4789
|
if (!recoveryPhase) recoveryPhase = "preparing";
|
|
4632
4790
|
armRecoveryLease();
|
|
@@ -4634,7 +4792,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4634
4792
|
budget.setRecoveryHold(true);
|
|
4635
4793
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4636
4794
|
await bus.fenceRecovery();
|
|
4637
|
-
{ const error =
|
|
4795
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4638
4796
|
const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
|
|
4639
4797
|
if (!blocked.length && permissions.size === 0 && recoveryReady()) {
|
|
4640
4798
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4661,6 +4819,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4661
4819
|
}
|
|
4662
4820
|
if (msg.op === "commit") {
|
|
4663
4821
|
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");
|
|
4822
|
+
// Person ruling 2026-10-19: upgrade is excluded from release resource gates; original readiness still binds.
|
|
4664
4823
|
recoveryPhase = "prepared";
|
|
4665
4824
|
bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
|
|
4666
4825
|
recoveryPeerSnapshot ??= Object.values(recoveryPeers());
|
|
@@ -4669,7 +4828,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4669
4828
|
...saved,
|
|
4670
4829
|
queueIds: bus.queueIds(saved.id),
|
|
4671
4830
|
// Recorded at commit, not prepare: a first turn landing in between flips it (#64).
|
|
4672
|
-
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
|
|
4831
|
+
...(definitionOf(saved.id)?.session.missingEvidence === "transcript" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.id, saved.sessionId) } : {}),
|
|
4673
4832
|
}));
|
|
4674
4833
|
const snapshot: RestartSnapshot = {
|
|
4675
4834
|
schemaVersion: 1,
|
|
@@ -4683,7 +4842,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4683
4842
|
peers,
|
|
4684
4843
|
integrity: integrity(),
|
|
4685
4844
|
};
|
|
4686
|
-
{ const error =
|
|
4845
|
+
{ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
|
|
4687
4846
|
try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
|
|
4688
4847
|
recoveryCommitted = true;
|
|
4689
4848
|
clearTimeout(recoveryLeaseTimer);
|
|
@@ -4717,9 +4876,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4717
4876
|
return recoveryError("unknown recovery operation");
|
|
4718
4877
|
}
|
|
4719
4878
|
|
|
4720
|
-
function claudePermissionLaunch(): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4879
|
+
function claudePermissionLaunch(peer = "claude"): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
|
|
4721
4880
|
try {
|
|
4722
|
-
const row = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
4881
|
+
const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
4723
4882
|
if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
|
|
4724
4883
|
return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
4725
4884
|
} catch { return undefined; }
|
|
@@ -4748,7 +4907,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4748
4907
|
if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
|
|
4749
4908
|
if (mode === "never-ask" && confirmed !== true) return { ok: false, error: "never-ask needs --yes or console confirmation" };
|
|
4750
4909
|
if (definitionOf(peer)?.transport === "claude-channel") {
|
|
4751
|
-
const launch = claudePermissionLaunch();
|
|
4910
|
+
const launch = claudePermissionLaunch(peer);
|
|
4752
4911
|
if (!launch || !(owner instanceof WsPeer) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
|
|
4753
4912
|
if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
|
|
4754
4913
|
} else if (owner instanceof CodexPeer) {
|
|
@@ -5021,18 +5180,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5021
5180
|
let expectedLaunch: string | undefined;
|
|
5022
5181
|
try {
|
|
5023
5182
|
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
5024
|
-
expectedLaunch = Array.isArray(rows) ? rows.find(row =>
|
|
5183
|
+
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
|
|
5025
5184
|
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
5026
5185
|
try {
|
|
5027
|
-
const launch = JSON.parse(readFileSync(join(opts.stateDir, "claude-launch
|
|
5186
|
+
const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
|
|
5028
5187
|
if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
|
|
5029
5188
|
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
5030
|
-
const current = claudeSession();
|
|
5189
|
+
const current = claudeSession(peer);
|
|
5031
5190
|
if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
|
|
5032
5191
|
if (!expectedLaunch || msg.nativeLaunchId === expectedLaunch) {
|
|
5033
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5192
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5034
5193
|
const launchId = typeof msg.nativeLaunchId === "string" ? msg.nativeLaunchId : undefined;
|
|
5035
|
-
const file = join(opts.stateDir, "claude-session
|
|
5194
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
5036
5195
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, ...(launchId ? { launchId } : {}), ...(transcript ? { transcriptPath: transcript } : {}) }), { mode: 0o600 });
|
|
5037
5196
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
5038
5197
|
nativeClaude = true;
|
|
@@ -5041,7 +5200,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5041
5200
|
}
|
|
5042
5201
|
if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
|
|
5043
5202
|
const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
|
|
5044
|
-
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch() : undefined;
|
|
5203
|
+
const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
|
|
5045
5204
|
const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
|
|
5046
5205
|
if (permission !== undefined && nativePeer instanceof WsPeer) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
|
|
5047
5206
|
const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
|
|
@@ -5053,10 +5212,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5053
5212
|
if (phase === "session") { recordSessions(); return void reply({ t: "facts", ok: true }); }
|
|
5054
5213
|
if ((phase === "start" || phase === "pre") && nativePeer instanceof WsPeer) {
|
|
5055
5214
|
const previous = nativeTurnStart.get(peer);
|
|
5056
|
-
const current = claudeSession();
|
|
5215
|
+
const current = claudeSession(peer);
|
|
5057
5216
|
if (phase === "start" || nativePeer.state !== "busy" || previous?.generation !== nativePeer.sessionGeneration || previous?.session !== sessionId || previous?.launch !== (current.launchId ?? "")) {
|
|
5058
|
-
const session = claudeSession();
|
|
5059
|
-
const baseline = session.sessionId && session.transcriptPath ?
|
|
5217
|
+
const session = claudeSession(peer);
|
|
5218
|
+
const baseline = session.sessionId && session.transcriptPath ? readClaudeTranscriptCompletion(session.sessionId, session.transcriptPath)?.id : undefined;
|
|
5060
5219
|
nativeTurnStart.set(peer, { at: Date.now(), ...(baseline ? { baseline } : {}), generation: nativePeer.sessionGeneration, session: sessionId!, launch: session.launchId ?? "" });
|
|
5061
5220
|
}
|
|
5062
5221
|
nativePeer.nativeStarted(sessionId!);
|
|
@@ -5075,12 +5234,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5075
5234
|
event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
|
|
5076
5235
|
hookStats.delete(peer);
|
|
5077
5236
|
if (id && nativePeer instanceof WsPeer) nativePeer.nativeStopped(sessionId!);
|
|
5078
|
-
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath)); }
|
|
5237
|
+
if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
|
|
5079
5238
|
};
|
|
5080
5239
|
if (!nativeClaude) { finishStop(); reply({ t: "facts", ok: true }); return; } // legacy bookkeeping cannot certify native completion
|
|
5081
|
-
const session = claudeSession();
|
|
5082
|
-
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath);
|
|
5083
|
-
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath); return session.sessionId && transcript ?
|
|
5240
|
+
const session = claudeSession(peer);
|
|
5241
|
+
let transcript = session.transcriptPath ?? claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5242
|
+
const readCompletion = () => { transcript ??= claudeTranscript(sessionId, msg.transcriptPath, peer); return session.sessionId && transcript ? readClaudeTranscriptCompletion(session.sessionId, transcript) : undefined; };
|
|
5084
5243
|
const completionId = (id: string) => createHash("sha256").update(`${sessionId}:${session.launchId ?? "legacy"}:${id}`).digest("hex");
|
|
5085
5244
|
const started = nativeTurnStart.get(peer);
|
|
5086
5245
|
const activeStart = !!started && started.session === sessionId && started.launch === (session.launchId ?? "") && nativePeer instanceof WsPeer && started.generation === nativePeer.sessionGeneration;
|
|
@@ -5091,7 +5250,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5091
5250
|
}
|
|
5092
5251
|
if (!started || !activeStart || !(nativePeer instanceof WsPeer)) { log("native Stop refused for claude: current native turn start unavailable"); return void reply({ t: "facts", ok: false }); }
|
|
5093
5252
|
const scopeCurrent = () => {
|
|
5094
|
-
const now = claudeSession();
|
|
5253
|
+
const now = claudeSession(peer);
|
|
5095
5254
|
return !stopping && bus.peers.get(peer) === nativePeer && nativePeer.sessionGeneration === started.generation && now.sessionId === started.session && (now.launchId ?? "") === started.launch;
|
|
5096
5255
|
};
|
|
5097
5256
|
const current = () => scopeCurrent() && nativeTurnStart.get(peer) === started;
|
|
@@ -5106,14 +5265,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5106
5265
|
if (nativeStopWaits.get(peer) === ownWait) nativeStopWaits.delete(peer);
|
|
5107
5266
|
if (!result.record) {
|
|
5108
5267
|
const reason = ({ unavailable: "completed transcript message unavailable", baseline: "completion still matches start baseline", "before-start": "completion timestamp precedes native start", superseded: "native start superseded during completion wait", verified: "completed transcript message unavailable" } as const)[result.reason];
|
|
5109
|
-
log(`native Stop refused for claude: ${reason}`);
|
|
5268
|
+
log(`native Stop refused for claude: ${reason}`);
|
|
5269
|
+
// #517: a peer left busy receives no pushed messages; say so where the person looks, once per turn.
|
|
5270
|
+
if (result.reason === "unavailable" && !stopUnconfirmedNoticed.has(started)) {
|
|
5271
|
+
stopUnconfirmedNoticed.add(started);
|
|
5272
|
+
notify(`${peer}: its turn end could not be confirmed from its transcript, so it stays busy and messages to it wait in its queue (hub_inbox reads them)`);
|
|
5273
|
+
}
|
|
5274
|
+
return;
|
|
5110
5275
|
}
|
|
5111
5276
|
const id = completionId(result.record.id);
|
|
5112
5277
|
if (scopeCurrent() && nativeStopsSeen.has(id)) return;
|
|
5113
5278
|
if (!current()) { log("native Stop refused for claude: native start superseded during completion wait"); return; }
|
|
5114
5279
|
nativeStopsSeen.add(id); nativeTurnStart.delete(peer); // consume before idle admits another native turn
|
|
5115
5280
|
if (transcript && !session.transcriptPath) {
|
|
5116
|
-
const file = join(opts.stateDir, "claude-session
|
|
5281
|
+
const file = join(opts.stateDir, claudeStateFile("claude-session", peer));
|
|
5117
5282
|
writeFileSync(`${file}.hook.tmp`, JSON.stringify({ at: Date.now(), instanceId, sessionId, launchId: session.launchId, transcriptPath: transcript }), { mode: 0o600 });
|
|
5118
5283
|
chmodSync(`${file}.hook.tmp`, 0o600); renameSync(`${file}.hook.tmp`, file);
|
|
5119
5284
|
}
|
|
@@ -5127,7 +5292,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5127
5292
|
if (!factsOn()) return void reply({ t: "facts", ok: true, ...permissionReply });
|
|
5128
5293
|
if (definitionOf(peer)?.transport === "claude-channel" && phase === "pre") observeProgress(peer, normalizeClaudeObservation(tool, input));
|
|
5129
5294
|
factSession(peer, sessionId);
|
|
5130
|
-
const transcript = claudeTranscript(sessionId, msg.transcriptPath);
|
|
5295
|
+
const transcript = claudeTranscript(sessionId, msg.transcriptPath, peer);
|
|
5131
5296
|
if (phase === "post") {
|
|
5132
5297
|
factsFor(peer).postTool(peer, toolUseId, tool, input);
|
|
5133
5298
|
readbacks(peer, transcript);
|
|
@@ -5202,6 +5367,23 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5202
5367
|
if (msg.op === "export") {
|
|
5203
5368
|
try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
|
|
5204
5369
|
}
|
|
5370
|
+
if (msg.op === "accept") {
|
|
5371
|
+
// #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
|
|
5372
|
+
if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
|
|
5373
|
+
const task = board.get(msg.id);
|
|
5374
|
+
const repo = msg.repo ?? task?.sdlc?.repo;
|
|
5375
|
+
if (!task || typeof repo !== "string") return void reply({ ok: false, error: task ? "spec acceptance needs --repo owner/repository (the task's receipt names none)" : "invalid lifecycle task id" });
|
|
5376
|
+
const linked = `${task.sdlc?.repo ?? ""}#${task.sdlc?.issue ?? ""}`;
|
|
5377
|
+
void readSpecEdit(repo, msg.issue, (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound)).then(edit => {
|
|
5378
|
+
if (!edit) return reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" });
|
|
5379
|
+
// The readback is for the receipt read before it; a relink meanwhile makes it someone else's evidence.
|
|
5380
|
+
const now = board.get(msg.id)?.sdlc;
|
|
5381
|
+
if (`${now?.repo ?? ""}#${now?.issue ?? ""}` !== linked) return reply({ ok: false, error: "the task's spec link changed during the readback; nothing was recorded, run ahub sdlc accept again" });
|
|
5382
|
+
try { const next = tasks.acceptSpec(USER, msg.id, { repo, issue: msg.issue, edit }); reply({ ok: true, acceptance: next.sdlc?.acceptance }); }
|
|
5383
|
+
catch (error) { reply({ ok: false, error: (error as Error).message }); }
|
|
5384
|
+
}, () => reply({ ok: false, error: "issue edit time unavailable; nothing was recorded" }));
|
|
5385
|
+
return;
|
|
5386
|
+
}
|
|
5205
5387
|
if (msg.op !== "status" && msg.op !== "explain") return void reply({ ok: false, error: "unknown SDLC operation" });
|
|
5206
5388
|
const selected = msg.id === undefined ? board.list().filter(task => task.state !== "backlog" && (task.sdlc || lifecycleConfig.mode !== "off" || lifecycleConfig.workflow === "gsd-lite")) : [board.get(Number(msg.id))].filter((task): task is Task => !!task);
|
|
5207
5389
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|