@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +15 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/dogfood.md +12 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +137 -4
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +10 -3
  13. package/docs/security.md +155 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  18. package/docs/verified.json +31 -15
  19. package/package.json +4 -3
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +5118 -4708
  22. package/src/adapters/claude-channel.ts +29 -6
  23. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  24. package/src/adapters/jcode.ts +3 -0
  25. package/src/adapters/registry.ts +1 -1
  26. package/src/cli/acon-register.ts +29 -0
  27. package/src/cli/handoff-args.ts +27 -0
  28. package/src/cli/handoff-output.ts +25 -0
  29. package/src/cli/help.ts +6 -3
  30. package/src/cli/identity.ts +7 -1
  31. package/src/cli/main.ts +76 -7
  32. package/src/cli/output.ts +8 -5
  33. package/src/cli/schedule-output.ts +8 -4
  34. package/src/cli/study-output.ts +71 -0
  35. package/src/cli/study.ts +58 -0
  36. package/src/cli/task-output.ts +1 -0
  37. package/src/hub/board.ts +871 -2
  38. package/src/hub/channel-project.ts +61 -0
  39. package/src/hub/control-client.ts +10 -1
  40. package/src/hub/daemon.ts +247 -11
  41. package/src/hub/envelope.ts +2 -0
  42. package/src/hub/events.ts +4 -1
  43. package/src/hub/execution-budget.ts +12 -0
  44. package/src/hub/handoff.ts +126 -0
  45. package/src/hub/hub-tools.ts +12 -2
  46. package/src/hub/manager.ts +64 -57
  47. package/src/hub/no-writes.ts +1 -1
  48. package/src/hub/path-policy.ts +12 -8
  49. package/src/hub/project.ts +14 -5
  50. package/src/hub/provenance.ts +30 -0
  51. package/src/hub/research-campaign.ts +265 -0
  52. package/src/hub/research-experiments.ts +445 -0
  53. package/src/hub/restart.ts +26 -0
  54. package/src/hub/schedules.ts +210 -9
  55. package/src/hub/session-identity.ts +49 -0
  56. package/src/hub/tasks.ts +894 -22
  57. package/src/hub/ui.ts +15 -6
  58. package/src/memory/capture.ts +2 -2
  59. package/src/ui/index.html +5 -3
  60. package/src/ui/office-space.ts +118 -0
  61. package/src/ui/plugin.ts +293 -0
  62. package/templates/AGENTS.block.md +1 -0
  63. package/templates/profiles/general.json +1 -1
  64. package/templates/profiles/research.json +4 -4
  65. package/templates/profiles/writing.json +1 -1
  66. package/templates/roles/research/analyst.md +1 -0
  67. package/templates/roles/research/fact-checker.md +1 -0
  68. package/templates/roles/research/research-lead.md +1 -0
  69. package/templates/roles/research/researcher.md +1 -0
  70. 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
- return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
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
- event({ type: "envelope", id: env.id, from: env.from, ...(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 } : {}) });
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
- return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
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 schedules pass 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
9017
- // its own timeout) before the peers stop, the board closes and the store closes, so no pass runs against a closed database.
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);
@@ -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
- | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
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[] }