@staix/agent-hub 0.24.0 → 0.25.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 +13 -0
- package/README.md +27 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +15 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +2 -1
- package/docs/dogfood.md +12 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +137 -4
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +10 -3
- package/docs/security.md +155 -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 +351 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5118 -4708
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +6 -3
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +76 -7
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +247 -11
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- 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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Which project and state directory the Claude channel plugin joins (#604 PR B, agent-hub#1030 item 10). It applies the same decision the CLI makes at startup: `projectContext` for an
|
|
2
|
+
// ordinary process, plus the one exception the CLI has for a declared extra instance, whose cwd is its own managed worktree and whose state directory is the main project's by design.
|
|
3
|
+
// Without this the plugin trusted an inherited AGENTHUB_STATE_DIR as is, so a Claude process started in `<repo>/.worktrees/<name>` that inherited the main checkout's variables joined the
|
|
4
|
+
// main repository's hub.
|
|
5
|
+
//
|
|
6
|
+
// Limit, stated plainly: this stops accidental and inherited-environment joins. It is not a defence against a same-user process that sets the variables and the launch evidence itself.
|
|
7
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
8
|
+
import { readInstanceConfig } from "./instance-config.ts";
|
|
9
|
+
import { effectiveInstanceRoles } from "./instances.ts";
|
|
10
|
+
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { canonicalPath, inheritedStateDir, projectContext, projectRoot, realPath } from "./project.ts";
|
|
14
|
+
|
|
15
|
+
export interface ChannelProject {
|
|
16
|
+
root: string; stateDir: string;
|
|
17
|
+
/** True when the state directory came from the inherited environment, proven. */
|
|
18
|
+
inherited: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Set when this process has no project of its own and an inherited state directory that records another project (agent-hub#1036): it joins that hub as a distinct session, never as the
|
|
21
|
+
* peer id it names. `label` is the origin part of the id the hub mints (`<peer>~<label>`), or empty to let the hub choose a short one.
|
|
22
|
+
*/
|
|
23
|
+
session?: { label: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `cwd` is the plugin process's working directory, `env` its environment, `policyRoles(root)` the configured roles of a project (the same merge the plugin and the CLI use).
|
|
28
|
+
* Returns `undefined` only when there is nothing to join at all: no project of its own and no inherited state directory. Everything else joins something (agent-hub#1036: nothing that
|
|
29
|
+
* works today is refused): the proven project, the cwd's own project, or, when the process cannot prove it belongs to the hub its environment names, that hub as a distinct session.
|
|
30
|
+
*/
|
|
31
|
+
export function channelProject(cwd: string, env: NodeJS.ProcessEnv, policyRoles: (root: string) => Record<string, string[]>): ChannelProject | undefined {
|
|
32
|
+
const override = env.AGENTHUB_STATE_DIR?.trim(), declared = env.AGENTHUB_PROJECT_DIR?.trim();
|
|
33
|
+
// A tools-mode helper (`AGENTHUB_MODE=tools`) is started by the hub or by an agent the hub started, with the state dir and project dir set for exactly that purpose: the Claude implementer child and
|
|
34
|
+
// an external-module instance run in their own managed worktree and must reach the owning hub, as they always did. The daemon checks their launch id itself at the hello. The root check below is
|
|
35
|
+
// for a channel session, a person's `claude` that may merely have inherited a pair (review thread on #1037; agent-hub#1036 AC4).
|
|
36
|
+
if (env.AGENTHUB_MODE === "tools" && override && declared) return { root: canonicalPath(declared), stateDir: canonicalPath(override), inherited: true };
|
|
37
|
+
// The origin label is left empty on purpose: the hub mints a short id, so no directory name reaches a header another agent reads.
|
|
38
|
+
const asSession = (): ChannelProject | undefined => override
|
|
39
|
+
? { root: declared ? canonicalPath(declared) : "", stateDir: canonicalPath(override), inherited: false, session: { label: "" } } : undefined;
|
|
40
|
+
let own: ReturnType<typeof projectContext>;
|
|
41
|
+
try { own = projectContext(cwd, {}); } catch { return asSession(); }
|
|
42
|
+
const peer = env.AGENTHUB_PEER_ID;
|
|
43
|
+
if (peer && declared && !definitionOf(peer)) {
|
|
44
|
+
// A declared extra instance: its workspace is the exact managed worktree the project declares for it, and its hub is the project that declares it.
|
|
45
|
+
try {
|
|
46
|
+
const root = realPath(declared), config = readInstanceConfig(root);
|
|
47
|
+
if (config.instances && Object.hasOwn(config.instances, peer)) {
|
|
48
|
+
const workspace = resolveInstanceExecutionCwd(root, peer, config.instances, effectiveInstanceRoles(peer, config.instances, policyRoles(root), config.catalog), config.catalog);
|
|
49
|
+
if (realPath(projectRoot(cwd)) !== realPath(workspace)) return asSession();
|
|
50
|
+
const stateDir = inheritedStateDir(root, env);
|
|
51
|
+
return stateDir ? { root, stateDir, inherited: true } : asSession();
|
|
52
|
+
}
|
|
53
|
+
} catch { return asSession(); }
|
|
54
|
+
}
|
|
55
|
+
const inherited = inheritedStateDir(own.root, env);
|
|
56
|
+
if (inherited) return { root: own.root, stateDir: inherited, inherited: true };
|
|
57
|
+
// No project of its own (no .agenthub/config.json up to the git boundary) and an inherited state directory that is not proven to belong here: it joins that hub as a distinct session.
|
|
58
|
+
// With a project of its own (a linked worktree, say) it keeps that project's own hub, as before.
|
|
59
|
+
if (override && !existsSync(join(own.root, ".agenthub", "config.json"))) return asSession();
|
|
60
|
+
return { root: own.root, stateDir: own.stateDir, inherited: false };
|
|
61
|
+
}
|
|
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
9
9
|
|
|
10
10
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
11
|
export const PROTOCOL = 16;
|
|
12
|
-
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor"] as const;
|
|
12
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "task_handoff", "session_identity"] as const;
|
|
13
13
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
14
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
15
15
|
|
|
@@ -42,6 +42,12 @@ export interface Hello {
|
|
|
42
42
|
launchId?: string;
|
|
43
43
|
/** Optional canonical native execution cwd; ownership requires independent host proof. */
|
|
44
44
|
cwd?: string;
|
|
45
|
+
/**
|
|
46
|
+
* agent-hub#1036: the plugin has no project of its own and its inherited state directory belongs to another project. It asks to join as a distinct session; the value is the origin label
|
|
47
|
+
* (possibly empty). The daemon mints `<peer>~<label>` for it and never binds the peer id it names. A daemon without the `session_identity` feature ignores the field, so a client checks
|
|
48
|
+
* `features` in `status.json` before sending it.
|
|
49
|
+
*/
|
|
50
|
+
session?: string;
|
|
45
51
|
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
46
52
|
standby?: boolean;
|
|
47
53
|
projectId?: string;
|
|
@@ -97,6 +103,8 @@ export function controlRequestTimeout(msg: Record<string, unknown>, requested =
|
|
|
97
103
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
98
104
|
export class ControlClient {
|
|
99
105
|
features: readonly string[] = [];
|
|
106
|
+
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
107
|
+
sessionId?: string;
|
|
100
108
|
private welcomeInstanceId?: string;
|
|
101
109
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
102
110
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
@@ -170,6 +178,7 @@ export class ControlClient {
|
|
|
170
178
|
}
|
|
171
179
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
172
180
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
181
|
+
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|
|
173
182
|
clearTimeout(timer);
|
|
174
183
|
resolve(client);
|
|
175
184
|
});
|
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";
|
|
@@ -175,7 +176,9 @@ import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts"
|
|
|
175
176
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
176
177
|
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
177
178
|
import { codeGroup } from "./task-code-group.ts";
|
|
178
|
-
import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
179
|
+
import { ControlClient, DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
180
|
+
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
181
|
+
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
179
182
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
180
183
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
|
|
181
184
|
import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
|
|
@@ -360,7 +363,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
360
363
|
export { stateDirFor };
|
|
361
364
|
|
|
362
365
|
/** 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"]);
|
|
366
|
+
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
367
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
365
368
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
366
369
|
|
|
@@ -697,6 +700,8 @@ interface Client {
|
|
|
697
700
|
authed: boolean;
|
|
698
701
|
role?: "peer" | "console" | "tools";
|
|
699
702
|
peer?: PeerId;
|
|
703
|
+
/** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
|
|
704
|
+
session?: true;
|
|
700
705
|
launchId?: string;
|
|
701
706
|
externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
|
|
702
707
|
toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
|
|
@@ -805,6 +810,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
805
810
|
const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
|
|
806
811
|
const implementerTranscripts = new Map<string, string>();
|
|
807
812
|
const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
|
|
813
|
+
/** #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. */
|
|
814
|
+
const envelopeOrigins = new Map<string, Origin>();
|
|
815
|
+
/** 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. */
|
|
816
|
+
const sessionIds = new Set<string>();
|
|
817
|
+
const rememberOrigin = (id: string, origin: Origin): void => { envelopeOrigins.set(id, origin); if (envelopeOrigins.size > 1024) envelopeOrigins.delete(envelopeOrigins.keys().next().value as string); };
|
|
818
|
+
const takeOrigin = (id: string): Origin | undefined => { const origin = envelopeOrigins.get(id); envelopeOrigins.delete(id); return origin; };
|
|
819
|
+
/**
|
|
820
|
+
* 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).
|
|
821
|
+
* 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`,
|
|
822
|
+
* `digest`) and remote room ids have none, as before. Evidence for the log, never a path or text.
|
|
823
|
+
*/
|
|
824
|
+
const adapterOrigin = (env: Envelope): Origin | undefined => {
|
|
825
|
+
if (env.from === "hub" || env.from === USER || env.from === "digest" || env.from.includes("@")) return undefined;
|
|
826
|
+
const peer = bus.peers.get(env.from);
|
|
827
|
+
if (!peer) return undefined;
|
|
828
|
+
// 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
|
|
829
|
+
// authority or ownership, so it is not an `isWsPeer` host branch (the #625 ratchet lists those, and this is not one).
|
|
830
|
+
const evidence = peer as { managedLaunchId?: unknown; ownedLaunchId?: unknown };
|
|
831
|
+
const launchId = [evidence.managedLaunchId, evidence.ownedLaunchId].find((value): value is string => typeof value === "string" && value.length > 0);
|
|
832
|
+
return originOf({ role: "peer", peer: env.from, ...(launchId ? { launchId } : {}) });
|
|
833
|
+
};
|
|
808
834
|
const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
|
|
809
835
|
const externalBindings = new Map<string, ExternalInstanceBinding>();
|
|
810
836
|
const externalSessions = new Map<string, string>();
|
|
@@ -1154,6 +1180,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1154
1180
|
for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
|
|
1155
1181
|
sock.data.invocationRequests?.clear();
|
|
1156
1182
|
nativeHookLaunches.endOwner(sock);
|
|
1183
|
+
if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
|
|
1157
1184
|
consoles.delete(sock);
|
|
1158
1185
|
operationalLogSubscribers.delete(sock);
|
|
1159
1186
|
sock.data.tail?.();
|
|
@@ -1353,6 +1380,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1353
1380
|
onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
|
|
1354
1381
|
startupCleanup.push(() => bus.closeJournal());
|
|
1355
1382
|
const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
|
|
1383
|
+
/**
|
|
1384
|
+
* 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
|
|
1385
|
+
* 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
|
|
1386
|
+
* 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
|
|
1387
|
+
* shells without an inherited id, owned ACP peers and the Claude implementer child are not affected.
|
|
1388
|
+
*/
|
|
1389
|
+
const sessionHello = (msg: Record<string, any>): boolean => {
|
|
1390
|
+
if (typeof msg.session === "string") return true;
|
|
1391
|
+
const peer = String(msg.peer ?? "");
|
|
1392
|
+
if (definitionOf(peer)?.transport !== "claude-channel" || definitionOf(peer)?.start.daemonPort === "claude-implementer") return false;
|
|
1393
|
+
try { const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId); return !!launch && typeof msg.launchId === "string" && msg.launchId !== launch.launchId; } catch { return false; }
|
|
1394
|
+
};
|
|
1356
1395
|
function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
|
|
1357
1396
|
if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
|
|
1358
1397
|
return { present: true, port: owner.frontendProxy };
|
|
@@ -2140,6 +2179,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2140
2179
|
// #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
2180
|
intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
|
|
2142
2181
|
partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
|
|
2182
|
+
studySource: id => sourceRegistry.get(id),
|
|
2183
|
+
experimentCheck: run => checkRunner.result(run),
|
|
2143
2184
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
2144
2185
|
piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
|
|
2145
2186
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
@@ -3166,6 +3207,71 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3166
3207
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3167
3208
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3168
3209
|
let taskOpsInFlight = 0;
|
|
3210
|
+
/**
|
|
3211
|
+
* #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
|
|
3212
|
+
* 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.
|
|
3213
|
+
*/
|
|
3214
|
+
async function handoffOut(by: PeerId, input: unknown): Promise<{ project: string; task: number; state: string; note?: string }> {
|
|
3215
|
+
const request = parseHandoff(input);
|
|
3216
|
+
const own = request.task === undefined ? undefined : board.get(request.task);
|
|
3217
|
+
if (request.task !== undefined && !own) throw new HandoffRefusal("invalid", `no task #${request.task} in this project to hand off from`);
|
|
3218
|
+
tasks.handoffOutAuthority(by, own);
|
|
3219
|
+
const self = { id: projectId, root: opts.cwd };
|
|
3220
|
+
const target = resolveTarget(request.project, registeredProjects(hubHome()).map(project => ({ id: project.id, root: project.root, stateDir: project.stateDir })), self, canonicalPath);
|
|
3221
|
+
const descriptor = readControl(target.stateDir);
|
|
3222
|
+
checkTarget(target, descriptor && { ...(descriptor.features ? { features: descriptor.features } : {}), ...(descriptor.projectId ? { projectId: descriptor.projectId } : {}) });
|
|
3223
|
+
let client: ControlClient;
|
|
3224
|
+
try { client = await ControlClient.connect(target.stateDir, { role: "console", projectRoot: descriptor!.cwd ?? target.root }, 5000); }
|
|
3225
|
+
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)`); }
|
|
3226
|
+
try {
|
|
3227
|
+
const { project: _project, task: originTask, ...body } = request;
|
|
3228
|
+
const reply = await client.request({ t: "task_handoff", origin: { projectId, peer: by, ...(originTask === undefined ? {} : { taskId: originTask }) }, request: body }, 30_000);
|
|
3229
|
+
if (!reply.ok) throw new HandoffRefusal("invalid", sanitize(String(reply.error ?? "the target refused the handoff")).slice(0, 300));
|
|
3230
|
+
const remote = Number(reply.task);
|
|
3231
|
+
if (!Number.isSafeInteger(remote) || remote < 1) throw new HandoffRefusal("invalid", "the target answered without a task id");
|
|
3232
|
+
tasks.handoffSent(by, { projectId: target.id }, remote, request.title, request.task);
|
|
3233
|
+
return { project: target.id, task: remote, state: String(reply.state ?? "proposed"), ...(typeof reply.note === "string" && reply.note ? { note: sanitize(reply.note).slice(0, 300) } : {}) };
|
|
3234
|
+
} finally { client.close(); }
|
|
3235
|
+
}
|
|
3236
|
+
/** 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. */
|
|
3237
|
+
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; };
|
|
3238
|
+
/** #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. */
|
|
3239
|
+
const handoffNotifyOrigin = (task: Task, entry: { event: string }): void => {
|
|
3240
|
+
const event: HandoffEvent | undefined = handoffEventFor(entry.event, task.state);
|
|
3241
|
+
const origin = event ? board.handoffOrigin(task.id) : undefined;
|
|
3242
|
+
if (!event || !origin) return;
|
|
3243
|
+
void (async () => {
|
|
3244
|
+
try {
|
|
3245
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === origin.projectId);
|
|
3246
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3247
|
+
if (!project || !descriptor || !descriptor.features?.includes("task_handoff")) return;
|
|
3248
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3249
|
+
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); }
|
|
3250
|
+
finally { client.close(); }
|
|
3251
|
+
} catch { /* the origin is down or refused: it reads the target's state on its next start */ }
|
|
3252
|
+
})();
|
|
3253
|
+
};
|
|
3254
|
+
/**
|
|
3255
|
+
* #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.
|
|
3256
|
+
* A target that is down is skipped: it is asked again on the next start. Best effort, in the background, never delaying readiness.
|
|
3257
|
+
*/
|
|
3258
|
+
const handoffCatchUp = (): void => {
|
|
3259
|
+
void (async () => {
|
|
3260
|
+
const open = board.handoffOutbound().filter(row => !["approved", "cancelled"].includes(row.state));
|
|
3261
|
+
const byProject = new Map<string, number[]>();
|
|
3262
|
+
for (const row of open) byProject.set(row.remoteProject, [...(byProject.get(row.remoteProject) ?? []), row.remoteTask]);
|
|
3263
|
+
for (const [remote, ids] of byProject) {
|
|
3264
|
+
try {
|
|
3265
|
+
const project = registeredProjects(hubHome()).find(candidate => candidate.id === remote);
|
|
3266
|
+
const descriptor = project && readControl(project.stateDir);
|
|
3267
|
+
if (!project || !descriptor?.features?.includes("task_handoff")) continue;
|
|
3268
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
|
|
3269
|
+
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); }
|
|
3270
|
+
finally { client.close(); }
|
|
3271
|
+
} catch { /* that target is down or refused: it is asked again on the next start */ }
|
|
3272
|
+
}
|
|
3273
|
+
})();
|
|
3274
|
+
};
|
|
3169
3275
|
const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
|
|
3170
3276
|
const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
|
|
3171
3277
|
const [by, op, input] = args;
|
|
@@ -3377,7 +3483,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3377
3483
|
}
|
|
3378
3484
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
3379
3485
|
// 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`);
|
|
3486
|
+
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
3487
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
3382
3488
|
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
3383
3489
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -3583,6 +3689,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3583
3689
|
}
|
|
3584
3690
|
case "hub_backlog_undo":
|
|
3585
3691
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
3692
|
+
// #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.
|
|
3693
|
+
// 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.
|
|
3694
|
+
case "hub_study_draft":
|
|
3695
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3696
|
+
case "hub_study_revise":
|
|
3697
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3698
|
+
case "hub_study_show":
|
|
3699
|
+
return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
|
|
3700
|
+
case "hub_study_handoff":
|
|
3701
|
+
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");
|
|
3702
|
+
return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
|
|
3703
|
+
// #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.
|
|
3704
|
+
case "hub_task_handoff":
|
|
3705
|
+
return JSON.stringify(await handoffOut(by, a));
|
|
3706
|
+
case "hub_task_handoffs":
|
|
3707
|
+
return JSON.stringify({ handoffs: tasks.handoffList() });
|
|
3708
|
+
// #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.
|
|
3709
|
+
case "study_draft":
|
|
3710
|
+
return JSON.stringify(await tasks.studyDraft(by, a));
|
|
3711
|
+
case "study_revise":
|
|
3712
|
+
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3713
|
+
case "study_approve":
|
|
3714
|
+
return JSON.stringify(await tasks.studyApprove(by, a));
|
|
3715
|
+
case "study_approve_protocol":
|
|
3716
|
+
return JSON.stringify(await tasks.experimentApproveProtocol(by, a));
|
|
3717
|
+
case "study_close":
|
|
3718
|
+
return JSON.stringify(await tasks.studyClose(by, a));
|
|
3719
|
+
|
|
3586
3720
|
case "hub_task_propose": {
|
|
3587
3721
|
const t = await tasks.propose(by, a);
|
|
3588
3722
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
@@ -3790,7 +3924,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3790
3924
|
const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
|
|
3791
3925
|
const fresh = board.get(task.id);
|
|
3792
3926
|
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);
|
|
3927
|
+
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
3928
|
}
|
|
3795
3929
|
case "deliverables": {
|
|
3796
3930
|
// #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
|
|
@@ -4134,6 +4268,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4134
4268
|
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
4269
|
if (tasks.queuePressureRechecksPending() !== 0) return false;
|
|
4136
4270
|
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
|
|
4271
|
+
if (tasks.studiesPending() !== 0) return false;
|
|
4137
4272
|
if (!recoveryPeerSnapshot) return true;
|
|
4138
4273
|
const current = recoveryPeers();
|
|
4139
4274
|
return recoveryPeerSnapshot.every((saved) => {
|
|
@@ -4283,7 +4418,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4283
4418
|
return row;
|
|
4284
4419
|
});
|
|
4285
4420
|
const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
|
|
4286
|
-
|
|
4421
|
+
const research = board.researchIntegrity();
|
|
4422
|
+
const researchDispatch = board.researchDispatchIntegrity();
|
|
4423
|
+
const researchExperiments = board.researchExperimentsIntegrity();
|
|
4424
|
+
const researchComparisons = board.researchComparisonIntegrity();
|
|
4425
|
+
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 } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
4287
4426
|
};
|
|
4288
4427
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
4289
4428
|
const integrityAs = (expected: unknown) => {
|
|
@@ -4451,6 +4590,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4451
4590
|
board.onChange = (task, entry) => {
|
|
4452
4591
|
try { taskChanged?.(task, entry); }
|
|
4453
4592
|
finally {
|
|
4593
|
+
try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
|
|
4454
4594
|
try { settleSupersededDeliveries(); }
|
|
4455
4595
|
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
4456
4596
|
}
|
|
@@ -4946,7 +5086,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4946
5086
|
} else {
|
|
4947
5087
|
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
5088
|
const env = e.env;
|
|
4949
|
-
|
|
5089
|
+
const origin = takeOrigin(env.id) ?? adapterOrigin(env);
|
|
5090
|
+
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
5091
|
}
|
|
4951
5092
|
if (!stopping) writeStatus();
|
|
4952
5093
|
});
|
|
@@ -7386,6 +7527,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7386
7527
|
...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
|
|
7387
7528
|
...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
|
|
7388
7529
|
...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
|
|
7530
|
+
...(tasks.studiesPending() !== 0 ? ["a research campaign admission in flight"] : []),
|
|
7389
7531
|
...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
|
|
7390
7532
|
...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
|
|
7391
7533
|
...(releasing ? ["ready tasks being released"] : []),
|
|
@@ -7427,6 +7569,38 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7427
7569
|
&& (!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
7570
|
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
7571
|
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";
|
|
7572
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_records")) {
|
|
7573
|
+
if (board.hasResearchState()) return "target lacks research_records; persisted research campaign records require a supporting daemon";
|
|
7574
|
+
let namesRunners = false;
|
|
7575
|
+
try {
|
|
7576
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7577
|
+
for (const name of CONFIG_FILES) {
|
|
7578
|
+
if (texts[name] === null) continue;
|
|
7579
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
7580
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
7581
|
+
const research = (raw as Record<string, unknown>).research;
|
|
7582
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
7583
|
+
namesRunners ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
7584
|
+
}
|
|
7585
|
+
} catch { return "research_records compatibility is unknown; configuration could not be read"; }
|
|
7586
|
+
// Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
|
|
7587
|
+
if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
|
|
7588
|
+
}
|
|
7589
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
|
|
7590
|
+
return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
|
|
7591
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
|
|
7592
|
+
return "target lacks research_dropped_offers; persisted study re-offers require a supporting daemon";
|
|
7593
|
+
// 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
|
|
7594
|
+
// 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.
|
|
7595
|
+
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)))
|
|
7596
|
+
return "target lacks session_identity; unsettled deliveries hold messages from distinct sessions that require a supporting daemon";
|
|
7597
|
+
// #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.
|
|
7598
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_handoff")) && board.hasHandoffState())
|
|
7599
|
+
return "target lacks task_handoff; stored handoffs require a supporting daemon";
|
|
7600
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_experiments")) && board.hasResearchExperimentState())
|
|
7601
|
+
return "target lacks research_experiments; persisted experiment records require a supporting daemon";
|
|
7602
|
+
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_comparisons")) && board.hasResearchComparisonState())
|
|
7603
|
+
return "target lacks research_comparisons; persisted comparison plans, approvals or protocol context require a supporting daemon";
|
|
7430
7604
|
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("project_autonomy")) {
|
|
7431
7605
|
let needsAutonomy = autonomyConfirmed.size > 0;
|
|
7432
7606
|
try {
|
|
@@ -7450,6 +7624,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7450
7624
|
} catch { return "action_policy_floor compatibility is unknown; configuration could not be read"; }
|
|
7451
7625
|
if (namesFloor) return "target lacks action_policy_floor; the project's action_policy.floor cannot be loaded by that daemon";
|
|
7452
7626
|
}
|
|
7627
|
+
// #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
|
|
7628
|
+
// entry in either file, even one an ordinary reload would reject, names the key; unreadable files are unknown, never absent.
|
|
7629
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("calendar_schedules")) {
|
|
7630
|
+
let namesCalendar = config.schedules.some(entry => entry.cron !== undefined || entry.tz !== undefined);
|
|
7631
|
+
try {
|
|
7632
|
+
const texts = configSourceTexts(opts.cwd);
|
|
7633
|
+
namesCalendar ||= CONFIG_FILES.some(name => {
|
|
7634
|
+
const list = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).schedules;
|
|
7635
|
+
return Array.isArray(list) && list.some(entry => !!entry && typeof entry === "object" && (Object.hasOwn(entry, "cron") || Object.hasOwn(entry, "tz")));
|
|
7636
|
+
});
|
|
7637
|
+
} catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
|
|
7638
|
+
if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
|
|
7639
|
+
}
|
|
7453
7640
|
return undefined;
|
|
7454
7641
|
};
|
|
7455
7642
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
@@ -7732,12 +7919,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7732
7919
|
c.authed = true;
|
|
7733
7920
|
c.role = msg.role;
|
|
7734
7921
|
if (c.role === "console") consoles.add(sock);
|
|
7922
|
+
else if (c.role === "tools" && sessionHello(msg)) {
|
|
7923
|
+
// 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
|
|
7924
|
+
// 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.
|
|
7925
|
+
const named = String(msg.peer ?? "");
|
|
7926
|
+
if (!PEER_ID.test(named) || named === USER || named === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7927
|
+
c.peer = mintSessionId(named, msg.session, id => sessionIds.has(id) || bus.peers.has(id), () => crypto.randomUUID().slice(0, 6));
|
|
7928
|
+
c.session = true; sessionIds.add(c.peer);
|
|
7929
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7930
|
+
}
|
|
7735
7931
|
else if (c.role === "tools") {
|
|
7736
7932
|
// 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
7933
|
c.peer = String(msg.peer ?? "");
|
|
7738
7934
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
7739
7935
|
if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
|
|
7740
7936
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
7937
|
+
if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
|
|
7741
7938
|
if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
|
|
7742
7939
|
const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
|
|
7743
7940
|
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 +8001,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7804
8001
|
if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
|
|
7805
8002
|
});
|
|
7806
8003
|
}
|
|
7807
|
-
|
|
8004
|
+
// #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.
|
|
8005
|
+
if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
|
|
8006
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
7808
8007
|
}
|
|
7809
8008
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
7810
8009
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
@@ -7812,6 +8011,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7812
8011
|
if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
|
|
7813
8012
|
if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
|
|
7814
8013
|
if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
|
|
8014
|
+
if (c.session) {
|
|
8015
|
+
// 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.
|
|
8016
|
+
const refusal = sessionGate(c.peer!, msg);
|
|
8017
|
+
if (refusal) return void reply({ t: msg.t, ok: false, error: refusal });
|
|
8018
|
+
}
|
|
7815
8019
|
switch (msg.t) {
|
|
7816
8020
|
case "request_cancel": {
|
|
7817
8021
|
if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
|
|
@@ -8184,6 +8388,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8184
8388
|
}
|
|
8185
8389
|
// Built first: limits count the audience it really has (a reply goes to the parent's sender).
|
|
8186
8390
|
let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
|
|
8391
|
+
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
|
|
8392
|
+
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
8393
|
try { bus.checkRemotePublication(env); }
|
|
8188
8394
|
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
8395
|
const offRoster = bus.offRosterRecipients(env);
|
|
@@ -8802,6 +9008,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8802
9008
|
try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
|
|
8803
9009
|
catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
|
|
8804
9010
|
}
|
|
9011
|
+
// #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.
|
|
9012
|
+
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
9013
|
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
9014
|
if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
|
|
8807
9015
|
if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
|
|
@@ -8973,6 +9181,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8973
9181
|
// Let the acknowledgement flush before closing the control listener.
|
|
8974
9182
|
setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
8975
9183
|
return;
|
|
9184
|
+
// #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
|
|
9185
|
+
// 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.
|
|
9186
|
+
case "task_handoff": {
|
|
9187
|
+
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" });
|
|
9188
|
+
if (recoveryActive()) return void reply({ t: "task_handoff", ok: false, error: "recovery is holding mutations; hand the task off again after it ends" });
|
|
9189
|
+
// 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.
|
|
9190
|
+
taskOpsInFlight++;
|
|
9191
|
+
const recoveryFence = (): string | undefined => recoveryActive() || stopping ? "recovery or shutdown is holding mutations; hand the task off again after it ends" : undefined;
|
|
9192
|
+
void tasks.handoffIn(msg.origin ?? {}, msg.request, recoveryFence).then(
|
|
9193
|
+
result => reply({ t: "task_handoff", ok: true, ...result }),
|
|
9194
|
+
error => reply({ t: "task_handoff", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff was refused" })).finally(() => { taskOpsInFlight--; });
|
|
9195
|
+
return;
|
|
9196
|
+
}
|
|
9197
|
+
case "task_handoff_event": {
|
|
9198
|
+
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" });
|
|
9199
|
+
try { reply({ t: "task_handoff_event", ok: true, ...tasks.handoffEvent(msg.event) }); }
|
|
9200
|
+
catch (error) { reply({ t: "task_handoff_event", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff event was refused" }); }
|
|
9201
|
+
return;
|
|
9202
|
+
}
|
|
9203
|
+
case "task_handoff_states": {
|
|
9204
|
+
// #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.
|
|
9205
|
+
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" });
|
|
9206
|
+
try { reply({ t: "task_handoff_states", ok: true, states: tasks.handoffStatesFor(msg.projectId, msg.tasks) }); }
|
|
9207
|
+
catch (error) { reply({ t: "task_handoff_states", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff state request was refused" }); }
|
|
9208
|
+
return;
|
|
9209
|
+
}
|
|
8976
9210
|
default:
|
|
8977
9211
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
8978
9212
|
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 +9225,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8991
9225
|
stopping = true;
|
|
8992
9226
|
roomCommands.stop();
|
|
8993
9227
|
tasks.stopQueuePressureRechecks();
|
|
9228
|
+
const studiesStopped = tasks.stopStudies();
|
|
8994
9229
|
let implementerShellCleanupFailed = false;
|
|
8995
9230
|
const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
|
|
8996
9231
|
terminalLaunches.clear();
|
|
@@ -9013,11 +9248,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9013
9248
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
9014
9249
|
for (const p of permissions.values()) p.done(undefined);
|
|
9015
9250
|
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) {
|
|
9251
|
+
// 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
|
|
9252
|
+
// its own timeout) before the peers stop, the board closes and the store closes, so no admission runs against a closed database.
|
|
9253
|
+
if (schedulesPass || manualSchedulePasses.size || tasks.studiesPending() !== 0) {
|
|
9019
9254
|
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?.(); })]);
|
|
9255
|
+
await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
|
|
9021
9256
|
clearTimeout(timer);
|
|
9022
9257
|
}
|
|
9023
9258
|
await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
|
|
@@ -9087,6 +9322,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9087
9322
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
9088
9323
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
9089
9324
|
ready = true;
|
|
9325
|
+
handoffCatchUp();
|
|
9090
9326
|
tasks.scheduleQueuePressureRecheck();
|
|
9091
9327
|
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|
|
9092
9328
|
const path = join(opts.cwd, ".agenthub", name);
|
package/src/hub/envelope.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface Envelope {
|
|
|
19
19
|
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true };
|
|
20
20
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
21
21
|
private?: boolean;
|
|
22
|
+
/** Sent by a distinct session (`<peer>~<origin>`, agent-hub#1036): a connection that named a peer id but is not that peer's current session. It grants nothing; it marks the sender for the log. */
|
|
23
|
+
session?: true;
|
|
22
24
|
ts: number;
|
|
23
25
|
}
|
|
24
26
|
|
package/src/hub/events.ts
CHANGED
|
@@ -22,7 +22,10 @@ export type HubEvent =
|
|
|
22
22
|
| { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
|
|
23
23
|
/** #445 C2: an invocation's ids, class and outcome. Never an argument, a result or a description. */
|
|
24
24
|
| { type: "invocation"; id: string; caller: string; target: string; capability: string; class: string; outcome: string; task?: number; bytes?: number }
|
|
25
|
-
|
|
25
|
+
/** `origin` (#604 PR B): the sending connection's role, peer and launch or instance id. Never a path or text; absent for an envelope the hub itself wrote. */
|
|
26
|
+
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string; origin?: import("./provenance.ts").Origin }
|
|
27
|
+
/** #604 PR B: one per peer-role or tools-role hello, with the same fields an envelope's `origin` carries. No cwd, root or token. */
|
|
28
|
+
| ({ type: "attach" } & import("./provenance.ts").Origin)
|
|
26
29
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
27
30
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
28
31
|
| { type: "quiet"; id: string; from: string; peers: string[] }
|