@staix/agent-hub 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -150,6 +150,7 @@ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, sta
|
|
|
150
150
|
import { basename, isAbsolute } from "node:path";
|
|
151
151
|
import { fileURLToPath } from "node:url";
|
|
152
152
|
import { canonicalPath, realPath, hubHome } from "./project.ts";
|
|
153
|
+
import { HandoffRefusal, checkTarget, parseHandoff, resolveTarget, handoffEventFor, type HandoffEvent } from "./handoff.ts";
|
|
153
154
|
import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
|
|
154
155
|
import type { BusEvent, PublishResult } from "./bus.ts";
|
|
155
156
|
import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, profileTaskTools } from "./hub-tools.ts";
|
|
@@ -173,15 +174,20 @@ import { githubOriginRepository, githubRemoteRepository } from "./task-repositor
|
|
|
173
174
|
import { Bus } from "./bus.ts";
|
|
174
175
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
175
176
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
177
|
+
import { assertHubDbCompatible } from "./db-schema.ts";
|
|
176
178
|
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
177
179
|
import { codeGroup } from "./task-code-group.ts";
|
|
178
|
-
import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
180
|
+
import { ControlClient, DAEMON_FEATURES, PROTOCOL, PROTOCOL_MIN, readControl, stateDirFor } from "./control-client.ts";
|
|
181
|
+
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
182
|
+
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
183
|
+
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
179
184
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
180
185
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
|
|
181
186
|
import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
|
|
182
187
|
import { MemoryClient } from "../memory/client.ts";
|
|
183
188
|
import { VERSION } from "../version.ts";
|
|
184
189
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
190
|
+
import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
|
|
185
191
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
186
192
|
import { projectChain } from "@staix/agent-mem";
|
|
187
193
|
import { recallFor } from "../memory/recall.ts";
|
|
@@ -196,6 +202,7 @@ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContin
|
|
|
196
202
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
|
|
197
203
|
import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
|
|
198
204
|
|
|
205
|
+
import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
|
|
199
206
|
import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
200
207
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
201
208
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
@@ -278,13 +285,15 @@ export interface HubConfig {
|
|
|
278
285
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
279
286
|
snapshots: { enabled: boolean; keep: number };
|
|
280
287
|
/** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
|
|
281
|
-
research: { enabled: boolean };
|
|
288
|
+
research: { enabled: boolean; runners?: Record<string, RegisteredRunnerV1> };
|
|
282
289
|
/** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
|
|
283
290
|
bench: { enabled: boolean };
|
|
284
291
|
/** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
|
|
285
292
|
peers: PeerStartConfig;
|
|
286
293
|
/** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
|
|
287
294
|
terminal: { open: string[] };
|
|
295
|
+
/** #608: the peers `ahub up` asks the hub to start, machine-local. Read by the CLI at a person's `ahub up` and by the dashboard manager's start; the daemon itself never starts one from it. */
|
|
296
|
+
start: { agents: string[] };
|
|
288
297
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
289
298
|
limits: LimitsConfig;
|
|
290
299
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
@@ -350,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
350
359
|
bench: { enabled: false },
|
|
351
360
|
peers: {},
|
|
352
361
|
terminal: { open: [] },
|
|
362
|
+
start: { agents: [...START_AGENTS_DEFAULT] },
|
|
353
363
|
limits: DEFAULT_LIMITS,
|
|
354
364
|
review: { adaptive: false, min_reviews: 5 },
|
|
355
365
|
recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
|
|
@@ -360,14 +370,29 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
360
370
|
export { stateDirFor };
|
|
361
371
|
|
|
362
372
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
363
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
373
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
364
374
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
365
375
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
366
376
|
|
|
367
377
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
368
378
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
369
379
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
370
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
380
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
381
|
+
|
|
382
|
+
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
383
|
+
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
384
|
+
const texts = configSourceTexts(cwd);
|
|
385
|
+
let present = false;
|
|
386
|
+
for (const name of CONFIG_FILES) {
|
|
387
|
+
if (texts[name] === null) continue;
|
|
388
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
389
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
390
|
+
const research = (raw as Record<string, unknown>).research;
|
|
391
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
392
|
+
present ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
393
|
+
}
|
|
394
|
+
return present;
|
|
395
|
+
}
|
|
371
396
|
|
|
372
397
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
373
398
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
@@ -475,6 +500,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
475
500
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
476
501
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
477
502
|
terminalTemplate(file.terminal?.open);
|
|
503
|
+
if (file.start != null && (typeof file.start !== "object" || Array.isArray(file.start) || Object.keys(file.start).some((key: string) => key !== "agents"))) throw new Error("start must be an object with an agents list");
|
|
504
|
+
if (file.start?.agents !== undefined) checkStartAgents(file.start.agents);
|
|
478
505
|
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
479
506
|
if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
|
|
480
507
|
delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
|
|
@@ -484,8 +511,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
484
511
|
if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
|
|
485
512
|
if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
|
|
486
513
|
}
|
|
514
|
+
if (file.research !== undefined && (!file.research || typeof file.research !== "object" || Array.isArray(file.research))) throw new Error("research must be an object");
|
|
515
|
+
const hasRunners = file.research && Object.hasOwn(file.research, "runners"), runnerDeclarations: unknown = hasRunners ? file.research.runners : undefined;
|
|
487
516
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
488
517
|
if (why) ignored.push(why);
|
|
518
|
+
if (hasRunners) file.research.runners = parseResearchRunners(runnerDeclarations);
|
|
489
519
|
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
490
520
|
return [file];
|
|
491
521
|
});
|
|
@@ -572,10 +602,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
572
602
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
|
|
573
603
|
...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
|
|
574
604
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
575
|
-
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
605
|
+
research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
|
|
576
606
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
577
607
|
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
578
608
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
609
|
+
start: { agents: file.start?.agents === undefined ? [...START_AGENTS_DEFAULT] : checkStartAgents(file.start.agents) },
|
|
579
610
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
580
611
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
581
612
|
recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
|
|
@@ -695,8 +726,11 @@ export interface DaemonOptions {
|
|
|
695
726
|
|
|
696
727
|
interface Client {
|
|
697
728
|
authed: boolean;
|
|
729
|
+
protocol?: number;
|
|
698
730
|
role?: "peer" | "console" | "tools";
|
|
699
731
|
peer?: PeerId;
|
|
732
|
+
/** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
|
|
733
|
+
session?: true;
|
|
700
734
|
launchId?: string;
|
|
701
735
|
externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
|
|
702
736
|
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
@@ -742,6 +776,8 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
742
776
|
}
|
|
743
777
|
|
|
744
778
|
export async function startDaemon(opts: DaemonOptions) {
|
|
779
|
+
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
780
|
+
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
745
781
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
746
782
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
747
783
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -805,6 +841,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
805
841
|
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
806
842
|
const implementerTranscripts = new Map<string, string>();
|
|
807
843
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
844
|
+
/** #604 PR B: envelope id to the connection that sent it, written where a connection builds an envelope and read once when the envelope event is logged. Bounded: an entry whose event never comes ages out. */
|
|
845
|
+
const envelopeOrigins = new Map<string, Origin>();
|
|
846
|
+
/** agent-hub#1036: the ids this daemon minted for live distinct sessions. Only the daemon makes them, so `PEER_ID` is not widened and no `@` reaches the remote-room paths. */
|
|
847
|
+
const sessionIds = new Set<string>();
|
|
848
|
+
const rememberOrigin = (id: string, origin: Origin): void => { envelopeOrigins.set(id, origin); if (envelopeOrigins.size > 1024) envelopeOrigins.delete(envelopeOrigins.keys().next().value as string); };
|
|
849
|
+
const takeOrigin = (id: string): Origin | undefined => { const origin = envelopeOrigins.get(id); envelopeOrigins.delete(id); return origin; };
|
|
850
|
+
/**
|
|
851
|
+
* agent-hub#1036: the origin of an envelope that no control connection sent, one an adapter published itself (a native peer's final answer goes through `peer.onMessage`, not the `send` case).
|
|
852
|
+
* It names the sending peer and, where the adapter knows one, the launch it runs under: a channel session's `managedLaunchId`, an owned Jcode's launch id. The hub's own writers (`hub`, `user`,
|
|
853
|
+
* `digest`) and remote room ids have none, as before. Evidence for the log, never a path or text.
|
|
854
|
+
*/
|
|
855
|
+
const adapterOrigin = (env: Envelope): Origin | undefined => {
|
|
856
|
+
if (env.from === "hub" || env.from === USER || env.from === "digest" || env.from.includes("@")) return undefined;
|
|
857
|
+
const peer = bus.peers.get(env.from);
|
|
858
|
+
if (!peer) return undefined;
|
|
859
|
+
// Read by shape, not by a native-branch predicate: a channel session reports `managedLaunchId`, an owned Jcode `ownedLaunchId`, and the value only labels the log. Nothing here decides
|
|
860
|
+
// authority or ownership, so it is not an `isWsPeer` host branch (the #625 ratchet lists those, and this is not one).
|
|
861
|
+
const evidence = peer as { managedLaunchId?: unknown; ownedLaunchId?: unknown };
|
|
862
|
+
const launchId = [evidence.managedLaunchId, evidence.ownedLaunchId].find((value): value is string => typeof value === "string" && value.length > 0);
|
|
863
|
+
return originOf({ role: "peer", peer: env.from, ...(launchId ? { launchId } : {}) });
|
|
864
|
+
};
|
|
808
865
|
const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
|
|
809
866
|
const externalBindings = new Map<string, ExternalInstanceBinding>();
|
|
810
867
|
const externalSessions = new Map<string, string>();
|
|
@@ -1154,6 +1211,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1154
1211
|
for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
|
|
1155
1212
|
sock.data.invocationRequests?.clear();
|
|
1156
1213
|
nativeHookLaunches.endOwner(sock);
|
|
1214
|
+
if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
|
|
1157
1215
|
consoles.delete(sock);
|
|
1158
1216
|
operationalLogSubscribers.delete(sock);
|
|
1159
1217
|
sock.data.tail?.();
|
|
@@ -1353,6 +1411,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1353
1411
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1354
1412
|
startupCleanup.push(() => bus.closeJournal());
|
|
1355
1413
|
const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
|
|
1414
|
+
/**
|
|
1415
|
+
* agent-hub#1036: whether a tools hello is a distinct session instead of the peer it names. True when the plugin asked for it (`session`: it has no project of its own and inherited this hub's
|
|
1416
|
+
* state), or when the name is a Claude channel peer and the hello PRESENTS a launch id that is not the current launch record's. An ABSENT id is never enough: the facts hook, a native
|
|
1417
|
+
* helper or a person's own shell connects as the peer with none, and it must keep working (agent-hub#1036 AC4). Everything else keeps its admission as before: codex, kimi, qwen, agent
|
|
1418
|
+
* shells without an inherited id, owned ACP peers and the Claude implementer child are not affected.
|
|
1419
|
+
*/
|
|
1420
|
+
const sessionHello = (msg: Record<string, any>): boolean => {
|
|
1421
|
+
if (typeof msg.session === "string") return true;
|
|
1422
|
+
const peer = String(msg.peer ?? "");
|
|
1423
|
+
if (definitionOf(peer)?.transport !== "claude-channel" || definitionOf(peer)?.start.daemonPort === "claude-implementer") return false;
|
|
1424
|
+
try { const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId); return !!launch && typeof msg.launchId === "string" && msg.launchId !== launch.launchId; } catch { return false; }
|
|
1425
|
+
};
|
|
1356
1426
|
function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
|
|
1357
1427
|
if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
|
|
1358
1428
|
return { present: true, port: owner.frontendProxy };
|
|
@@ -2009,6 +2079,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2009
2079
|
};
|
|
2010
2080
|
const tasks: Tasks = new Tasks({
|
|
2011
2081
|
releaseWindow,
|
|
2082
|
+
researchRunners: () => {
|
|
2083
|
+
if (config.research.runners === undefined) return {};
|
|
2084
|
+
const why = configRefusal(opts.cwd, "config.local.json");
|
|
2085
|
+
if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
|
|
2086
|
+
return parseResearchRunners(config.research.runners);
|
|
2087
|
+
},
|
|
2012
2088
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
2013
2089
|
contextAdmission: () => ({
|
|
2014
2090
|
policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
|
|
@@ -2140,6 +2216,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2140
2216
|
// #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
|
|
2141
2217
|
intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
|
|
2142
2218
|
partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
|
|
2219
|
+
studySource: id => sourceRegistry.get(id),
|
|
2220
|
+
experimentCheck: run => checkRunner.result(run),
|
|
2143
2221
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
2144
2222
|
piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
|
|
2145
2223
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
@@ -3166,6 +3244,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3166
3244
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3167
3245
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3168
3246
|
let taskOpsInFlight = 0;
|
|
3247
|
+
/**
|
|
3248
|
+
* #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
|
|
3249
|
+
* status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
|
|
3250
|
+
*/
|
|
3251
|
+
async function handoffOut(by: PeerId, input: unknown): Promise<{ project: string; task: number; state: string; note?: string }> {
|
|
3252
|
+
const request = parseHandoff(input);
|
|
3253
|
+
const own = request.task === undefined ? undefined : board.get(request.task);
|
|
3254
|
+
if (request.task !== undefined && !own) throw new HandoffRefusal("invalid", `no task #${request.task} in this project to hand off from`);
|
|
3255
|
+
tasks.handoffOutAuthority(by, own);
|
|
3256
|
+
const self = { id: projectId, root: opts.cwd };
|
|
3257
|
+
const target = resolveTarget(request.project, registeredProjects(hubHome()).map(project => ({ id: project.id, root: project.root, stateDir: project.stateDir })), self, canonicalPath);
|
|
3258
|
+
const descriptor = readControl(target.stateDir);
|
|
3259
|
+
checkTarget(target, descriptor && { ...(descriptor.features ? { features: descriptor.features } : {}), ...(descriptor.projectId ? { projectId: descriptor.projectId } : {}) });
|
|
3260
|
+
let client: ControlClient;
|
|
3261
|
+
try { client = await ControlClient.connect(target.stateDir, { role: "console", projectRoot: descriptor!.cwd ?? target.root }, 5000); }
|
|
3262
|
+
catch { throw new HandoffRefusal("not_running", `the hub of ${target.id} did not answer; run \`ahub up\` in ${target.root}, then hand the task off again (handoffs are not queued)`); }
|
|
3263
|
+
try {
|
|
3264
|
+
const { project: _project, task: originTask, ...body } = request;
|
|
3265
|
+
const reply = await client.request({ t: "task_handoff", origin: { projectId, peer: by, ...(originTask === undefined ? {} : { taskId: originTask }) }, request: body }, 30_000);
|
|
3266
|
+
if (!reply.ok) throw new HandoffRefusal("invalid", sanitize(String(reply.error ?? "the target refused the handoff")).slice(0, 300));
|
|
3267
|
+
const remote = Number(reply.task);
|
|
3268
|
+
if (!Number.isSafeInteger(remote) || remote < 1) throw new HandoffRefusal("invalid", "the target answered without a task id");
|
|
3269
|
+
tasks.handoffSent(by, { projectId: target.id }, remote, request.title, request.task);
|
|
3270
|
+
return { project: target.id, task: remote, state: String(reply.state ?? "proposed"), ...(typeof reply.note === "string" && reply.note ? { note: sanitize(reply.note).slice(0, 300) } : {}) };
|
|
3271
|
+
} finally { client.close(); }
|
|
3272
|
+
}
|
|
3273
|
+
/** The head a handoff reports: the owner's `refs.commit`, else the receipt's verified head. Only a full 40-hex SHA crosses; anything else is dropped. */
|
|
3274
|
+
const handoffHead = (task: Task): string | undefined => { const head = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; return typeof head === "string" && /^[0-9a-f]{40}$/.test(head) ? head : undefined; };
|
|
3275
|
+
/** #604 TARGET side: tell the origin project's daemon about a state change of a task it handed us. Best effort and never blocking a board write: an origin that is down reads the state later. */
|
|
3276
|
+
const handoffNotifyOrigin = (task: Task, entry: { event: string }): void => {
|
|
3277
|
+
const event: HandoffEvent | undefined = handoffEventFor(entry.event, task.state);
|
|
3278
|
+
const origin = event ? board.handoffOrigin(task.id) : undefined;
|
|
3279
|
+
if (!event || !origin) return;
|
|
3280
|
+
void (async () => {
|
|
3281
|
+
try {
|
|
3282
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === origin.projectId);
|
|
3283
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3284
|
+
if (!project || !descriptor || !descriptor.features?.includes("task_handoff")) return;
|
|
3285
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3286
|
+
try { await client.request({ t: "task_handoff_event", event: { projectId, remoteTask: task.id, state: event, ...(typeof task.sdlc?.pr === "number" ? { pr: task.sdlc.pr } : {}), ...(handoffHead(task) ? { head: handoffHead(task) } : {}) } }, 5000); }
|
|
3287
|
+
finally { client.close(); }
|
|
3288
|
+
} catch { /* the origin is down or refused: it reads the target's state on its next start */ }
|
|
3289
|
+
})();
|
|
3290
|
+
};
|
|
3291
|
+
/**
|
|
3292
|
+
* #604 ORIGIN side, on start: for every handoff this project made that has not reached a final state, ask the target (if it is running) where the task stands now, and record the answer.
|
|
3293
|
+
* A target that is down is skipped: it is asked again on the next start. Best effort, in the background, never delaying readiness.
|
|
3294
|
+
*/
|
|
3295
|
+
const handoffCatchUp = (): void => {
|
|
3296
|
+
void (async () => {
|
|
3297
|
+
const open = board.handoffOutbound().filter(row => !["approved", "cancelled"].includes(row.state));
|
|
3298
|
+
const byProject = new Map<string, number[]>();
|
|
3299
|
+
for (const row of open) byProject.set(row.remoteProject, [...(byProject.get(row.remoteProject) ?? []), row.remoteTask]);
|
|
3300
|
+
for (const [remote, ids] of byProject) {
|
|
3301
|
+
try {
|
|
3302
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === remote);
|
|
3303
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3304
|
+
if (!project || !descriptor?.features?.includes("task_handoff")) continue;
|
|
3305
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3306
|
+
try { const reply = await client.request({ t: "task_handoff_states", projectId, tasks: ids.slice(0, 200) }, 5000); if (reply.ok && Array.isArray(reply.states)) tasks.handoffCaughtUp(remote, reply.states); }
|
|
3307
|
+
finally { client.close(); }
|
|
3308
|
+
} catch { /* that target is down or refused: it is asked again on the next start */ }
|
|
3309
|
+
}
|
|
3310
|
+
})();
|
|
3311
|
+
};
|
|
3169
3312
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
3170
3313
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
3171
3314
|
const [by, op, input] = args;
|
|
@@ -3377,7 +3520,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3377
3520
|
}
|
|
3378
3521
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
3379
3522
|
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
3380
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3523
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3381
3524
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
3382
3525
|
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
3383
3526
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -3583,6 +3726,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3583
3726
|
}
|
|
3584
3727
|
case "hub_backlog_undo":
|
|
3585
3728
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
3729
|
+
// #601: the study tools only call Tasks. Draft and revise are the configured planner's (Tasks checks planningAuthority and the private turn); none of them approves.
|
|
3730
|
+
// The arguments go to Tasks whole, never re-picked field by field: an unknown field is refused there (`studyFields`), not silently dropped here, and MCP schemas are not enforced on the way in.
|
|
3731
|
+
case "hub_study_draft":
|
|
3732
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3733
|
+
case "hub_study_revise":
|
|
3734
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3735
|
+
case "hub_study_show":
|
|
3736
|
+
return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
|
|
3737
|
+
case "hub_study_handoff":
|
|
3738
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
|
|
3739
|
+
return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
|
|
3740
|
+
// #604 (agent-hub#1030): a handoff to another project. Tasks checks who may send it and the PII gates; `handoffOut` resolves the target, asks its daemon and records the answer.
|
|
3741
|
+
case "hub_task_handoff":
|
|
3742
|
+
return JSON.stringify(await handoffOut(by, a));
|
|
3743
|
+
case "hub_task_handoffs":
|
|
3744
|
+
return JSON.stringify({ handoffs: tasks.handoffList() });
|
|
3745
|
+
// #601: the person's own forms (a read is hub_study_show, which every connection may use). `by` is USER only on the console path; approval and close are never a tool.
|
|
3746
|
+
case "study_draft":
|
|
3747
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3748
|
+
case "study_revise":
|
|
3749
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3750
|
+
case "study_approve":
|
|
3751
|
+
return JSON.stringify(await tasks.studyApprove(by, a));
|
|
3752
|
+
case "study_approve_protocol":
|
|
3753
|
+
return JSON.stringify(await tasks.experimentApproveProtocol(by, a));
|
|
3754
|
+
case "study_close":
|
|
3755
|
+
return JSON.stringify(await tasks.studyClose(by, a));
|
|
3756
|
+
|
|
3586
3757
|
case "hub_task_propose": {
|
|
3587
3758
|
const t = await tasks.propose(by, a);
|
|
3588
3759
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
@@ -3790,7 +3961,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3790
3961
|
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
3791
3962
|
const fresh = board.get(task.id);
|
|
3792
3963
|
const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
|
|
3793
|
-
return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
3964
|
+
return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(publicTask.handoffOrigin ? { handoffOrigin: publicTask.handoffOrigin } : {}), ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
3794
3965
|
}
|
|
3795
3966
|
case "deliverables": {
|
|
3796
3967
|
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
@@ -4134,6 +4305,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4134
4305
|
if (tasks.autoPullsPending() !== 0) return false; // #785 P1c: an automatic backlog pull mid-admission would write after the commit's digest; its own line keeps the line above exact for scripts/seeds.json
|
|
4135
4306
|
if (tasks.queuePressureRechecksPending() !== 0) return false;
|
|
4136
4307
|
if (tasks.schedulesPending() !== 0) return false; // #512a: a scheduled run mid-admission (its PII screen is awaited) would write after the commit's digest; the hold fence refuses its write, and this waits for it to end
|
|
4308
|
+
if (tasks.studiesPending() !== 0) return false;
|
|
4137
4309
|
if (!recoveryPeerSnapshot) return true;
|
|
4138
4310
|
const current = recoveryPeers();
|
|
4139
4311
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -4265,6 +4437,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4265
4437
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
4266
4438
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
4267
4439
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
4440
|
+
if (typeof metadata.controlProtocol === "number" && Number.isSafeInteger(metadata.controlProtocol) && metadata.controlProtocol > 0) row.controlProtocol = metadata.controlProtocol;
|
|
4268
4441
|
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
4269
4442
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
4270
4443
|
return [id, row];
|
|
@@ -4283,7 +4456,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4283
4456
|
return row;
|
|
4284
4457
|
});
|
|
4285
4458
|
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
4286
|
-
|
|
4459
|
+
const research = board.researchIntegrity();
|
|
4460
|
+
const researchDispatch = board.researchDispatchIntegrity();
|
|
4461
|
+
const researchExperiments = board.researchExperimentsIntegrity();
|
|
4462
|
+
const researchComparisons = board.researchComparisonIntegrity();
|
|
4463
|
+
const researchRunners = board.runnerReceiptIntegrity();
|
|
4464
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
4287
4465
|
};
|
|
4288
4466
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
4289
4467
|
const integrityAs = (expected: unknown) => {
|
|
@@ -4373,7 +4551,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4373
4551
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
4374
4552
|
// Every supported peer's membership, for launchers that run outside the console role (#484).
|
|
4375
4553
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
4376
|
-
protocol: PROTOCOL,
|
|
4554
|
+
protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
|
|
4377
4555
|
features: DAEMON_FEATURES,
|
|
4378
4556
|
configuredAdapters: configuredIds,
|
|
4379
4557
|
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
@@ -4451,6 +4629,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4451
4629
|
board.onChange = (task, entry) => {
|
|
4452
4630
|
try { taskChanged?.(task, entry); }
|
|
4453
4631
|
finally {
|
|
4632
|
+
try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
|
|
4454
4633
|
try { settleSupersededDeliveries(); }
|
|
4455
4634
|
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
4456
4635
|
}
|
|
@@ -4946,7 +5125,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4946
5125
|
} else {
|
|
4947
5126
|
log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
4948
5127
|
const env = e.env;
|
|
4949
|
-
|
|
5128
|
+
const origin = takeOrigin(env.id) ?? adapterOrigin(env);
|
|
5129
|
+
event({ type: "envelope", id: env.id, from: env.from, ...(origin ? { origin } : {}), ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
|
|
4950
5130
|
}
|
|
4951
5131
|
if (!stopping) writeStatus();
|
|
4952
5132
|
});
|
|
@@ -6567,6 +6747,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6567
6747
|
// Retired dictionaries never enter active publication; hub ids remain opaque keys.
|
|
6568
6748
|
if (prefix === "routing.targets" || prefix === "routing.routes") return [];
|
|
6569
6749
|
if (prefix === "routing.hub_routes") return [prefix];
|
|
6750
|
+
if (prefix === "research.runners") return [prefix];
|
|
6570
6751
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
6571
6752
|
return prefix ? [prefix] : [];
|
|
6572
6753
|
}
|
|
@@ -7386,6 +7567,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7386
7567
|
...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
|
|
7387
7568
|
...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
|
|
7388
7569
|
...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
|
|
7570
|
+
...(tasks.studiesPending() !== 0 ? ["a research campaign admission in flight"] : []),
|
|
7389
7571
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
7390
7572
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
7391
7573
|
...(releasing ? ["ready tasks being released"] : []),
|
|
@@ -7427,6 +7609,44 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7427
7609
|
&& (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
|
|
7428
7610
|
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";
|
|
7429
7611
|
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";
|
|
7612
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_records")) {
|
|
7613
|
+
if (board.hasResearchState()) return "target lacks research_records; persisted research campaign records require a supporting daemon";
|
|
7614
|
+
let namesRunners = false;
|
|
7615
|
+
try {
|
|
7616
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7617
|
+
for (const name of CONFIG_FILES) {
|
|
7618
|
+
if (texts[name] === null) continue;
|
|
7619
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
7620
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
7621
|
+
const research = (raw as Record<string, unknown>).research;
|
|
7622
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
7623
|
+
namesRunners ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
7624
|
+
}
|
|
7625
|
+
} catch { return "research_records compatibility is unknown; configuration could not be read"; }
|
|
7626
|
+
// Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
|
|
7627
|
+
if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
|
|
7628
|
+
}
|
|
7629
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_runners")) {
|
|
7630
|
+
if (board.hasRunnerReceipts()) return "target lacks research_runners; persisted runner attestations require a supporting daemon";
|
|
7631
|
+
try {
|
|
7632
|
+
if (config.research.runners !== undefined || rawResearchRunnersPresent(opts.cwd)) return "target lacks research_runners; the project's research.runners requires a supporting daemon";
|
|
7633
|
+
} catch { return "research_runners compatibility is unknown; configuration could not be read"; }
|
|
7634
|
+
}
|
|
7635
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
|
|
7636
|
+
return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
|
|
7637
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
|
|
7638
|
+
return "target lacks research_dropped_offers; persisted study re-offers require a supporting daemon";
|
|
7639
|
+
// agent-hub#1036: an unsettled delivery that holds a distinct session's message is an envelope with `session: true` and a minted `<peer>~<id>` sender. A target without the feature
|
|
7640
|
+
// would read that sender as a peer it does not know and queue a reply to it; this is its own label, so a daemon that has the older floors but not this one is still refused.
|
|
7641
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("session_identity")) && bus.queueList(undefined, true).some(row => (row.originals as Envelope[]).some(env => env.session === true)))
|
|
7642
|
+
return "target lacks session_identity; unsettled deliveries hold messages from distinct sessions that require a supporting daemon";
|
|
7643
|
+
// #604: a handoff row in either direction (a task another project handed us, or one we handed over) needs a target that knows `task_handoff`; without it the origin is silently lost.
|
|
7644
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_handoff")) && board.hasHandoffState())
|
|
7645
|
+
return "target lacks task_handoff; stored handoffs require a supporting daemon";
|
|
7646
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_experiments")) && board.hasResearchExperimentState())
|
|
7647
|
+
return "target lacks research_experiments; persisted experiment records require a supporting daemon";
|
|
7648
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_comparisons")) && board.hasResearchComparisonState())
|
|
7649
|
+
return "target lacks research_comparisons; persisted comparison plans, approvals or protocol context require a supporting daemon";
|
|
7430
7650
|
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("project_autonomy")) {
|
|
7431
7651
|
let needsAutonomy = autonomyConfirmed.size > 0;
|
|
7432
7652
|
try {
|
|
@@ -7450,8 +7670,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7450
7670
|
} catch { return "action_policy_floor compatibility is unknown; configuration could not be read"; }
|
|
7451
7671
|
if (namesFloor) return "target lacks action_policy_floor; the project's action_policy.floor cannot be loaded by that daemon";
|
|
7452
7672
|
}
|
|
7673
|
+
// #1017: an older target refuses a `cron` or `tz` key in a `schedules` entry ("cron not supported yet"), so the project would not load there. The raw files decide, as for the floor: a calendar
|
|
7674
|
+
// entry in either file, even one an ordinary reload would reject, names the key; unreadable files are unknown, never absent.
|
|
7675
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("calendar_schedules")) {
|
|
7676
|
+
let namesCalendar = config.schedules.some(entry => entry.cron !== undefined || entry.tz !== undefined);
|
|
7677
|
+
try {
|
|
7678
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7679
|
+
namesCalendar ||= CONFIG_FILES.some(name => {
|
|
7680
|
+
const list = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).schedules;
|
|
7681
|
+
return Array.isArray(list) && list.some(entry => !!entry && typeof entry === "object" && (Object.hasOwn(entry, "cron") || Object.hasOwn(entry, "tz")));
|
|
7682
|
+
});
|
|
7683
|
+
} catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
|
|
7684
|
+
if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
|
|
7685
|
+
}
|
|
7686
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("schema_guard"))
|
|
7687
|
+
return "target lacks schema_guard; controlled recovery requires a guard-aware runtime";
|
|
7453
7688
|
return undefined;
|
|
7454
7689
|
};
|
|
7690
|
+
// Preserve lifecycle fences before capability probes; the prepare branch rechecks them after resume capture awaits.
|
|
7691
|
+
if (msg.op === "prepare") {
|
|
7692
|
+
if (rosterChanging) return recoveryError("roster change is in progress; wait for ahub status before preparing recovery");
|
|
7693
|
+
if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
|
|
7694
|
+
if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
|
|
7695
|
+
}
|
|
7455
7696
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
7456
7697
|
if (checkTarget) {
|
|
7457
7698
|
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
@@ -7719,7 +7960,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7719
7960
|
if (!c.authed) {
|
|
7720
7961
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
7721
7962
|
if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
|
|
7722
|
-
|
|
7963
|
+
const offered = protocolWindow(msg.v, msg.vMin), selectedProtocol = offered && negotiateProtocol({ v: PROTOCOL, vMin: PROTOCOL_MIN }, offered);
|
|
7964
|
+
if (selectedProtocol === undefined) {
|
|
7723
7965
|
// An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
|
|
7724
7966
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
7725
7967
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
@@ -7730,14 +7972,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7730
7972
|
try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
|
|
7731
7973
|
if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
|
|
7732
7974
|
c.authed = true;
|
|
7975
|
+
c.protocol = selectedProtocol;
|
|
7733
7976
|
c.role = msg.role;
|
|
7734
7977
|
if (c.role === "console") consoles.add(sock);
|
|
7978
|
+
else if (c.role === "tools" && sessionHello(msg)) {
|
|
7979
|
+
// agent-hub#1036: a connection that names a peer id but is not that peer's current session gets an id of its own instead of becoming the peer. It can send and read; it holds none of the
|
|
7980
|
+
// peer's board roles, is never a bus peer (no delivery, no push) and replaces nobody. Nothing is refused: a hello that worked before still works, as a session.
|
|
7981
|
+
const named = String(msg.peer ?? "");
|
|
7982
|
+
if (!PEER_ID.test(named) || named === USER || named === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7983
|
+
c.peer = mintSessionId(named, msg.session, id => sessionIds.has(id) || bus.peers.has(id), () => crypto.randomUUID().slice(0, 6));
|
|
7984
|
+
c.session = true; sessionIds.add(c.peer);
|
|
7985
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7986
|
+
}
|
|
7735
7987
|
else if (c.role === "tools") {
|
|
7736
7988
|
// Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
|
|
7737
7989
|
c.peer = String(msg.peer ?? "");
|
|
7738
7990
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7739
7991
|
if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
|
|
7740
7992
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
7993
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7741
7994
|
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
7742
7995
|
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
7743
7996
|
if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
|
|
@@ -7804,7 +8057,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7804
8057
|
if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
|
|
7805
8058
|
});
|
|
7806
8059
|
}
|
|
7807
|
-
|
|
8060
|
+
// #604 PR B: one attach event per admitted peer or tools hello, with the same fields an envelope's `origin` carries. A refused hello returned above and writes none.
|
|
8061
|
+
if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
|
|
8062
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: c.protocol, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
7808
8063
|
}
|
|
7809
8064
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
7810
8065
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
@@ -7812,6 +8067,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7812
8067
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
7813
8068
|
if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
|
|
7814
8069
|
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
8070
|
+
if (c.session) {
|
|
8071
|
+
// agent-hub#1036: a distinct session sends and reads, and nothing else. The gate runs before any case, so a new control message is refused to a session unless it is added to the list.
|
|
8072
|
+
const refusal = sessionGate(c.peer!, msg);
|
|
8073
|
+
if (refusal) return void reply({ t: msg.t, ok: false, error: refusal });
|
|
8074
|
+
}
|
|
7815
8075
|
switch (msg.t) {
|
|
7816
8076
|
case "request_cancel": {
|
|
7817
8077
|
if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
|
|
@@ -8068,11 +8328,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8068
8328
|
const plan = msg.plan as UpgradePlan;
|
|
8069
8329
|
const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
|
|
8070
8330
|
if (!plan || !["restart", "upgrade"].includes(plan.kind) || plan.projects.some(row => row.terminals.length > 0) || planFingerprint(body) !== fingerprint || fingerprint !== raw.planFingerprint || plan.kind !== raw.kind || existsSync(operationPath(raw.operationId))
|
|
8071
|
-
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock() || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8331
|
+
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock(undefined, opts.cwd) || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8332
|
+
const { recoveryScope } = await import("../cli/upgrade.ts"); current();
|
|
8333
|
+
const scope = recoveryScope(plan);
|
|
8334
|
+
if (scope !== undefined && scope !== opts.cwd) throw new RecoveryAuthorityError();
|
|
8072
8335
|
if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
|
|
8073
8336
|
if (plan.kind === "upgrade") {
|
|
8074
|
-
//
|
|
8337
|
+
// Scoped upgrades bind the source's one registered project; retained shared upgrades still cover every affected project.
|
|
8075
8338
|
const affected = registeredProjects().filter(project => {
|
|
8339
|
+
if (scope !== undefined) return project.root === scope;
|
|
8076
8340
|
const control = readControl(project.stateDir);
|
|
8077
8341
|
return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
|
|
8078
8342
|
});
|
|
@@ -8085,23 +8349,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8085
8349
|
if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
|
|
8086
8350
|
for (const id of selected.reconnectOnly) {
|
|
8087
8351
|
const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
|
|
8088
|
-
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
|
|
8352
|
+
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation", "controlProtocol"].includes(key)) || !row.observation
|
|
8089
8353
|
|| Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
|
|
8090
8354
|
|| !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8355
|
+
|| !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
|
|
8091
8356
|
|| row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
|
|
8092
8357
|
const generation = owner.sessionGeneration;
|
|
8093
8358
|
const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
|
|
8094
|
-
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8359
|
+
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8360
|
+
|| owner.recoveryMetadata().controlProtocol !== row.controlProtocol) throw new RecoveryAuthorityError();
|
|
8095
8361
|
reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
|
|
8362
|
+
&& owner.recoveryMetadata().controlProtocol === row.controlProtocol
|
|
8096
8363
|
&& definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
|
|
8097
8364
|
reconnect.set(id, row.observation);
|
|
8098
8365
|
}
|
|
8099
8366
|
}
|
|
8100
8367
|
if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
|
|
8101
|
-
const normalize = (row: { id: string; state: string; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8368
|
+
const normalize = (row: { id: string; state: string; controlProtocol?: number; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8102
8369
|
const launch = row.args ?? row.launch ?? {};
|
|
8103
8370
|
const observed = reconnect.get(row.id);
|
|
8104
|
-
return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8371
|
+
return { id: row.id, active: row.state !== "offline", controlProtocol: row.controlProtocol, threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8105
8372
|
...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
|
|
8106
8373
|
...(observed ? { kind: row.observation?.kind ?? definitionOf(row.id)?.kind, attached: row.observation?.attached ?? (isWsPeer(bus.peers.get(row.id)) && (bus.peers.get(row.id) as import("../adapters/channel-peer.ts").WsPeer).attached) } : {}),
|
|
8107
8374
|
sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
|
|
@@ -8115,9 +8382,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8115
8382
|
if (raw.kind === "restart") {
|
|
8116
8383
|
if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
|
|
8117
8384
|
} else {
|
|
8118
|
-
const staged = join(hubHome(), "releases", plan.version);
|
|
8119
|
-
|
|
8120
|
-
|
|
8385
|
+
const staged = join(scope === undefined ? hubHome() : realPath(hubHome()), "releases", plan.version);
|
|
8386
|
+
const expectedTarget = scope === undefined ? join(staged, "node_modules/@staix/agent-hub") : join(staged, `source-${raw.targetDigest}`);
|
|
8387
|
+
if (raw.targetRoot !== expectedTarget) throw new RecoveryAuthorityError();
|
|
8388
|
+
// A new/expired grant still needs registry proof; verification of an already issued grant below uses only retained target bytes.
|
|
8389
|
+
const verified = JSON.parse(readFileSync(join(scope === undefined ? staged : join(staged, "registry"), "verified.json"), "utf8"));
|
|
8121
8390
|
if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
|
|
8122
8391
|
}
|
|
8123
8392
|
if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
|
|
@@ -8184,6 +8453,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8184
8453
|
}
|
|
8185
8454
|
// Built first: limits count the audience it really has (a reply goes to the parent's sender).
|
|
8186
8455
|
let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
|
|
8456
|
+
if (c.session) env = { ...env, session: true }; // agent-hub#1036: sent by a distinct session; `from` is its minted id, and the header a recipient reads shows it as is
|
|
8457
|
+
rememberOrigin(env.id, originOf({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) })); // the connection that sent it, for events.jsonl (never a path or text)
|
|
8187
8458
|
try { bus.checkRemotePublication(env); }
|
|
8188
8459
|
catch (error) { return void reply({ t: "sent", ok: false, error: error instanceof Error && /^(?:room|remote) /.test(error.message) ? sanitize(error.message).slice(0, 8192) : "message admission unavailable" }); }
|
|
8189
8460
|
const offRoster = bus.offRosterRecipients(env);
|
|
@@ -8802,6 +9073,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8802
9073
|
try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
|
|
8803
9074
|
catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
|
|
8804
9075
|
}
|
|
9076
|
+
// #601: ahub study is the person's. A tools connection (any agent) may use the hub_study_* tools, never these op names, whatever Tasks would answer.
|
|
9077
|
+
if (String(msg.op).startsWith("study_") && c.role !== "console") return void reply({ t: "task", ok: false, error: "ahub study draft, revise, approve and close are console commands; agents use the hub_study_* tools" });
|
|
8805
9078
|
if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
|
|
8806
9079
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
8807
9080
|
if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
|
|
@@ -8973,6 +9246,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8973
9246
|
// Let the acknowledgement flush before closing the control listener.
|
|
8974
9247
|
setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
8975
9248
|
return;
|
|
9249
|
+
// #604 (agent-hub#1030): another project's daemon hands us a task, or reports a state change of one we handed it. Console role only (the same token and the same machine user as every
|
|
9250
|
+
// control message): an agent connection never reaches these. The origin peer named in the request is a record, never an identity here, and nothing in it grants a role.
|
|
9251
|
+
case "task_handoff": {
|
|
9252
|
+
if (c.role !== "console") return void reply({ t: "task_handoff", ok: false, error: "a handoff comes from another project's hub, not from an agent connection" });
|
|
9253
|
+
if (recoveryActive()) return void reply({ t: "task_handoff", ok: false, error: "recovery is holding mutations; hand the task off again after it ends" });
|
|
9254
|
+
// Counted like a task op: a recovery commit waits for it (`recoveryReady` reads `taskOpsInFlight`), and the fence is asked again right before the board write, after the PII screen.
|
|
9255
|
+
taskOpsInFlight++;
|
|
9256
|
+
const recoveryFence = (): string | undefined => recoveryActive() || stopping ? "recovery or shutdown is holding mutations; hand the task off again after it ends" : undefined;
|
|
9257
|
+
void tasks.handoffIn(msg.origin ?? {}, msg.request, recoveryFence).then(
|
|
9258
|
+
result => reply({ t: "task_handoff", ok: true, ...result }),
|
|
9259
|
+
error => reply({ t: "task_handoff", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff was refused" })).finally(() => { taskOpsInFlight--; });
|
|
9260
|
+
return;
|
|
9261
|
+
}
|
|
9262
|
+
case "task_handoff_event": {
|
|
9263
|
+
if (c.role !== "console") return void reply({ t: "task_handoff_event", ok: false, error: "a handoff event comes from another project's hub, not from an agent connection" });
|
|
9264
|
+
try { reply({ t: "task_handoff_event", ok: true, ...tasks.handoffEvent(msg.event) }); }
|
|
9265
|
+
catch (error) { reply({ t: "task_handoff_event", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff event was refused" }); }
|
|
9266
|
+
return;
|
|
9267
|
+
}
|
|
9268
|
+
case "task_handoff_states": {
|
|
9269
|
+
// #604: the origin asks, after a restart, where the tasks it handed us stand now. Console role only, and only for tasks whose recorded origin is the asking project.
|
|
9270
|
+
if (c.role !== "console") return void reply({ t: "task_handoff_states", ok: false, error: "a handoff state request comes from another project's hub, not from an agent connection" });
|
|
9271
|
+
try { reply({ t: "task_handoff_states", ok: true, states: tasks.handoffStatesFor(msg.projectId, msg.tasks) }); }
|
|
9272
|
+
catch (error) { reply({ t: "task_handoff_states", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff state request was refused" }); }
|
|
9273
|
+
return;
|
|
9274
|
+
}
|
|
8976
9275
|
default:
|
|
8977
9276
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
8978
9277
|
if (msg.rid !== undefined) reply({ t: String(msg.t), ok: false, error: `this hub does not know "${msg.t}" (restart it: ahub kill && ahub up)` });
|
|
@@ -8991,6 +9290,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8991
9290
|
stopping = true;
|
|
8992
9291
|
roomCommands.stop();
|
|
8993
9292
|
tasks.stopQueuePressureRechecks();
|
|
9293
|
+
const studiesStopped = tasks.stopStudies();
|
|
8994
9294
|
let implementerShellCleanupFailed = false;
|
|
8995
9295
|
const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
|
|
8996
9296
|
terminalLaunches.clear();
|
|
@@ -9013,11 +9313,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9013
9313
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
9014
9314
|
for (const p of permissions.values()) p.done(undefined);
|
|
9015
9315
|
try {
|
|
9016
|
-
// A
|
|
9017
|
-
// its own timeout) before the peers stop, the board closes and the store closes, so no
|
|
9018
|
-
if (schedulesPass || manualSchedulePasses.size) {
|
|
9316
|
+
// A schedule or study admission may be inside its PII screen: `stopping` is set, so its fence refuses the write when the screen returns. Wait for it (bounded, the screen has
|
|
9317
|
+
// its own timeout) before the peers stop, the board closes and the store closes, so no admission runs against a closed database.
|
|
9318
|
+
if (schedulesPass || manualSchedulePasses.size || tasks.studiesPending() !== 0) {
|
|
9019
9319
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
9020
|
-
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
9320
|
+
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
9021
9321
|
clearTimeout(timer);
|
|
9022
9322
|
}
|
|
9023
9323
|
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
@@ -9087,6 +9387,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9087
9387
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
9088
9388
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
9089
9389
|
ready = true;
|
|
9390
|
+
handoffCatchUp();
|
|
9090
9391
|
tasks.scheduleQueuePressureRecheck();
|
|
9091
9392
|
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|
|
9092
9393
|
const path = join(opts.cwd, ".agenthub", name);
|