@staix/agent-hub 0.23.1 → 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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +32 -5
  3. package/docs/agent-notes/adapters.md +5 -2
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +16 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +3 -0
  8. package/docs/dogfood.md +16 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +198 -5
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +19 -4
  13. package/docs/security.md +225 -8
  14. package/docs/specs/2026-09-19-agent-hub-design.md +54 -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-10-31-0.24.0.md +63 -0
  18. package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
  19. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  20. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  21. package/docs/verified.json +31 -15
  22. package/package.json +4 -3
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +5120 -4710
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/compat.ts +3 -3
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
  28. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry.ts +1 -1
  31. package/src/cli/acon-register.ts +29 -0
  32. package/src/cli/autonomy-homes.ts +33 -0
  33. package/src/cli/autonomy-offline.ts +75 -0
  34. package/src/cli/autonomy-projects.ts +103 -0
  35. package/src/cli/autonomy.ts +44 -0
  36. package/src/cli/claude-homes.ts +123 -0
  37. package/src/cli/claude-profile-hook.ts +130 -0
  38. package/src/cli/handoff-args.ts +27 -0
  39. package/src/cli/handoff-output.ts +25 -0
  40. package/src/cli/help.ts +8 -4
  41. package/src/cli/identity.ts +8 -1
  42. package/src/cli/main.ts +174 -33
  43. package/src/cli/output.ts +8 -5
  44. package/src/cli/schedule-output.ts +8 -4
  45. package/src/cli/setup-homes.ts +46 -0
  46. package/src/cli/setup.ts +1 -1
  47. package/src/cli/study-output.ts +71 -0
  48. package/src/cli/study.ts +58 -0
  49. package/src/cli/task-output.ts +1 -0
  50. package/src/hub/action-class.ts +203 -14
  51. package/src/hub/action-enforce.ts +8 -6
  52. package/src/hub/action-policy.ts +95 -9
  53. package/src/hub/autonomy-consent.ts +173 -0
  54. package/src/hub/autonomy-defaults.ts +58 -0
  55. package/src/hub/autonomy.ts +232 -0
  56. package/src/hub/board.ts +871 -2
  57. package/src/hub/channel-project.ts +61 -0
  58. package/src/hub/config-apply.ts +3 -2
  59. package/src/hub/confirmation-matrix.ts +202 -0
  60. package/src/hub/control-client.ts +10 -1
  61. package/src/hub/daemon.ts +546 -27
  62. package/src/hub/envelope.ts +2 -0
  63. package/src/hub/events.ts +4 -1
  64. package/src/hub/execution-budget.ts +12 -0
  65. package/src/hub/handoff.ts +126 -0
  66. package/src/hub/hub-tools.ts +12 -2
  67. package/src/hub/manager.ts +64 -57
  68. package/src/hub/no-writes.ts +1 -1
  69. package/src/hub/path-policy.ts +12 -8
  70. package/src/hub/project.ts +14 -5
  71. package/src/hub/provenance.ts +30 -0
  72. package/src/hub/research-campaign.ts +265 -0
  73. package/src/hub/research-experiments.ts +445 -0
  74. package/src/hub/restart.ts +26 -0
  75. package/src/hub/schedules.ts +210 -9
  76. package/src/hub/session-identity.ts +49 -0
  77. package/src/hub/settings.ts +105 -8
  78. package/src/hub/task-repository.ts +11 -6
  79. package/src/hub/tasks.ts +894 -22
  80. package/src/hub/ui.ts +15 -6
  81. package/src/memory/capture.ts +2 -2
  82. package/src/ui/index.html +5 -3
  83. package/src/ui/office-space.ts +118 -0
  84. package/src/ui/plugin.ts +293 -0
  85. package/templates/AGENTS.block.md +1 -0
  86. package/templates/presets/autonomous.json +161 -0
  87. package/templates/presets/confirmation-matrix.json +943 -0
  88. package/templates/profiles/general.json +1 -1
  89. package/templates/profiles/research.json +4 -4
  90. package/templates/profiles/writing.json +1 -1
  91. package/templates/roles/research/analyst.md +1 -0
  92. package/templates/roles/research/fact-checker.md +1 -0
  93. package/templates/roles/research/research-lead.md +1 -0
  94. package/templates/roles/research/researcher.md +1 -0
  95. package/templates/roles/research/statistician.md +1 -0
package/src/hub/daemon.ts CHANGED
@@ -21,8 +21,9 @@ import { createClaudeFailureTrace, type ClaudeFailureTrace } from "./claude-fail
21
21
  import { buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH, validateNativePermissionRequest } from "./native-permission-request.ts";
22
22
  import { createClaudeLaunchProof, readClaudeLaunchProof } from "./claude-launch-proof.ts";
23
23
  import { InboxPolling } from "./inbox-polling.ts";
24
- import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
24
+ import { ActionBudgets, actionPolicyConfig, floorEnforcement, floorReads, type ActionPolicyConfig } from "./action-policy.ts";
25
25
  import { actionGate, claudeActionClasses } from "./action-enforce.ts";
26
+ import type { FloorFacts } from "./action-class.ts";
26
27
  import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
27
28
  import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
28
29
  import type { DocEvidence } from "./sdlc-release.ts";
@@ -90,7 +91,7 @@ import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type Fold
90
91
  import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
91
92
  import type { ScannedFile } from "./schedules.ts";
92
93
  import { corpusConfig } from "./corpus-config.ts";
93
- import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
94
+ import { ALL_MACHINE_LOCAL, configRefusal, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
94
95
  import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
95
96
  import { resolveSettledRoles } from "./settled-roles.ts";
96
97
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
@@ -148,7 +149,8 @@ import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
148
149
  import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
149
150
  import { basename, isAbsolute } from "node:path";
150
151
  import { fileURLToPath } from "node:url";
151
- import { realPath, hubHome } from "./project.ts";
152
+ import { canonicalPath, realPath, hubHome } from "./project.ts";
153
+ import { HandoffRefusal, checkTarget, parseHandoff, resolveTarget, handoffEventFor, type HandoffEvent } from "./handoff.ts";
152
154
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
153
155
  import type { BusEvent, PublishResult } from "./bus.ts";
154
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";
@@ -163,14 +165,20 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig,
163
165
  import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
164
166
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
165
167
  import { withChecklistLine } from "./learning.ts";
166
- import { LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
167
- import { githubOriginRepository } from "./task-repository.ts";
168
+ import { autonomyConsentStateDir, createAutonomyConsent, type AutonomyConsentValues } from "./autonomy-consent.ts";
169
+ import { insideRoot } from "./path-policy.ts";
170
+ import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys, autonomyStatus, type AutonomyEnvironment, type AutonomyPeer } from "./autonomy.ts";
171
+ import { readAutonomyDefaults } from "./autonomy-defaults.ts";
172
+ import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
173
+ import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
168
174
  import { Bus } from "./bus.ts";
169
175
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
170
176
  import { DeliveryJournal } from "./delivery-journal.ts";
171
177
  import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
172
178
  import { codeGroup } from "./task-code-group.ts";
173
- 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";
174
182
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
175
183
  import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
176
184
  import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
@@ -355,7 +363,7 @@ export const DEFAULT_CONFIG: HubConfig = {
355
363
  export { stateDirFor };
356
364
 
357
365
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
358
- 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"]);
359
367
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
360
368
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
361
369
 
@@ -474,6 +482,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
474
482
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
475
483
  delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
476
484
  delete file.permission_default_sources; // provenance is loader-owned, never a file claim
485
+ delete file._autonomy; // Inert restoration metadata, never policy or approval provenance.
477
486
  if (file.checks?.runner === "remote" || file.checks?.remote !== undefined) {
478
487
  if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
479
488
  if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
@@ -691,6 +700,8 @@ interface Client {
691
700
  authed: boolean;
692
701
  role?: "peer" | "console" | "tools";
693
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;
694
705
  launchId?: string;
695
706
  externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
696
707
  toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
@@ -799,6 +810,27 @@ export async function startDaemon(opts: DaemonOptions) {
799
810
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
800
811
  const implementerTranscripts = new Map<string, string>();
801
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
+ };
802
834
  const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
803
835
  const externalBindings = new Map<string, ExternalInstanceBinding>();
804
836
  const externalSessions = new Map<string, string>();
@@ -944,7 +976,7 @@ export async function startDaemon(opts: DaemonOptions) {
944
976
  return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
945
977
  };
946
978
  // #514b: undo resolves the managed checklist key, which is not a settings row, and every other key through the project-scoped lookup above (declared instances, the adapter catalog, a stale roster key).
947
- const undoableDef = (key: unknown): SettingDef | string => key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
979
+ const undoableDef = (key: unknown): SettingDef | string => key === AUTONOMY_UNDO.key ? AUTONOMY_UNDO : key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
948
980
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
949
981
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
950
982
  let rosterChanging: string | undefined;
@@ -1148,6 +1180,7 @@ export async function startDaemon(opts: DaemonOptions) {
1148
1180
  for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
1149
1181
  sock.data.invocationRequests?.clear();
1150
1182
  nativeHookLaunches.endOwner(sock);
1183
+ if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
1151
1184
  consoles.delete(sock);
1152
1185
  operationalLogSubscribers.delete(sock);
1153
1186
  sock.data.tail?.();
@@ -1347,6 +1380,18 @@ export async function startDaemon(opts: DaemonOptions) {
1347
1380
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1348
1381
  startupCleanup.push(() => bus.closeJournal());
1349
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
+ };
1350
1395
  function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
1351
1396
  if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
1352
1397
  return { present: true, port: owner.frontendProxy };
@@ -2134,6 +2179,8 @@ export async function startDaemon(opts: DaemonOptions) {
2134
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`.
2135
2180
  intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
2136
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),
2137
2184
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
2138
2185
  piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
2139
2186
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
@@ -3160,6 +3207,71 @@ export async function startDaemon(opts: DaemonOptions) {
3160
3207
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3161
3208
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3162
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
+ };
3163
3275
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
3164
3276
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
3165
3277
  const [by, op, input] = args;
@@ -3371,7 +3483,7 @@ export async function startDaemon(opts: DaemonOptions) {
3371
3483
  }
3372
3484
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
3373
3485
  // shared memory and a new task could be routed to a cloud peer without matching any pattern.
3374
- 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`);
3375
3487
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
3376
3488
  // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
3377
3489
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
@@ -3577,6 +3689,34 @@ export async function startDaemon(opts: DaemonOptions) {
3577
3689
  }
3578
3690
  case "hub_backlog_undo":
3579
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
+
3580
3720
  case "hub_task_propose": {
3581
3721
  const t = await tasks.propose(by, a);
3582
3722
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
@@ -3784,7 +3924,7 @@ export async function startDaemon(opts: DaemonOptions) {
3784
3924
  const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
3785
3925
  const fresh = board.get(task.id);
3786
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" };
3787
- 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);
3788
3928
  }
3789
3929
  case "deliverables": {
3790
3930
  // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
@@ -3855,20 +3995,71 @@ export async function startDaemon(opts: DaemonOptions) {
3855
3995
  }
3856
3996
  // Runtime choices stay in this daemon only; recovery reads project defaults anew.
3857
3997
  const defaultSources = config.permission_default_sources ?? Object.fromEntries(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, { mode, source: "embedded configuration" }]));
3858
- const pendingPermissionDefaults = new Map(Object.entries(defaultSources).filter(([, value]) => value.mode === "never-ask").map(([peer, value]) => [peer, value.source]));
3998
+ const autonomyConsentDir = autonomyConsentStateDir(hubHome(), projectId);
3999
+ const autonomyConsent = createAutonomyConsent(autonomyConsentDir, projectId);
4000
+ const autonomyOwnershipWitness = createAutonomyConsent(join(autonomyConsentDir, "ownership"), projectId, "ownership");
4001
+ const autonomyUndoWitness = createAutonomyConsent(join(autonomyConsentDir, "undo"), projectId, "undo");
4002
+ const autonomyWitnessValues = (peer: "ownership" | "undo", digest: string): AutonomyConsentValues => ({ [peer]: { digest } });
4003
+ const ownAutonomyMetadata = (metadata: unknown) => {
4004
+ const digest = autonomyOwnershipDigest(metadata);
4005
+ if (digest) autonomyOwnershipWitness.confirm(autonomyWitnessValues("ownership", digest));
4006
+ else autonomyOwnershipWitness.clear();
4007
+ };
4008
+ const autonomyConsentLocationSafe = () => {
4009
+ try { const held = canonicalPath(autonomyConsentDir); const roots = [opts.cwd, ...executionRoots.values(), ...Object.values(config.instances ?? {}).flatMap(instance => instance.worktree ? [resolve(opts.cwd, instance.worktree)] : [])]; return !roots.some(root => insideRoot(relative(canonicalPath(root), held))); }
4010
+ catch { return false; }
4011
+ };
4012
+ const autonomyConsentTrusted = () => autonomyConsentLocationSafe() && configRefusal(opts.cwd, "config.local.json") === undefined;
4013
+ function autonomyConsentValues(candidate: HubConfig = config): AutonomyConsentValues {
4014
+ return Object.fromEntries(Object.entries(candidate.permission_modes).filter(([peer, mode]) => mode === "never-ask" && candidate.permission_default_sources?.[peer]?.source === ".agenthub/config.local.json")
4015
+ .map(([peer, mode]) => [peer, { [`permission_modes.${peer}`]: mode }]));
4016
+ }
4017
+ // Machine opt-in is a second host-owned approval trigger. Offline CLI writes never issue consent.
4018
+ const machineAutonomy = readAutonomyDefaults(hubHome(), [opts.cwd, ...executionRoots.values(), ...Object.values(config.instances ?? {}).flatMap(instance => instance.worktree ? [resolve(opts.cwd, instance.worktree)] : [])]);
4019
+ if (machineAutonomy.notice) log(machineAutonomy.notice);
4020
+ if (machineAutonomy.enabled && autonomyConsentTrusted()) {
4021
+ try {
4022
+ const peers: AutonomyPeer[] = SUPPORTED_PEERS.map(id => ({ id, kind: definitionOf(id)?.kind ?? id, unmanaged: !!definitionOf(id)?.permission.unmanaged,
4023
+ ...(declarations[id]?.start_mode ? { form: declarations[id]!.start_mode } : {}) }));
4024
+ // The daemon is detached even for a terminal project; explicit Orca/CI evidence selects those rows.
4025
+ const operations = planAutonomy(autonomyEnvironment(process.env, true), key => {
4026
+ const def = settingDef(key);
4027
+ return typeof def === "string" || def.store !== "config" ? undefined : { store: def.store, values: def.values, locked: def.locked };
4028
+ }, peers);
4029
+ const current = autonomyConsentValues(), observed = autonomyConsentValues(loadConfig(opts.cwd, undefined, undefined, externalDefinitions));
4030
+ const retained = autonomyConsent.confirmed(current, true);
4031
+ const approved: AutonomyConsentValues = Object.fromEntries([...retained].map(peer => [peer, current[peer]!]));
4032
+ for (const operation of operations) if (operation.key.startsWith("permission_modes.") && operation.value === "never-ask") {
4033
+ const peer = operation.key.slice("permission_modes.".length);
4034
+ if (current[peer]?.[operation.key] === operation.value && observed[peer]?.[operation.key] === operation.value) approved[peer] = current[peer]!;
4035
+ }
4036
+ if (Object.keys(approved).some(peer => !retained.has(peer))) {
4037
+ if (!autonomyConsentTrusted() || readAutonomyDefaults(hubHome(), [opts.cwd, ...executionRoots.values()]).fingerprint !== machineAutonomy.fingerprint) throw new Error("machine autonomy evidence changed during startup");
4038
+ autonomyConsent.confirm(approved);
4039
+ }
4040
+ } catch { log("Machine autonomy consent was not issued: preset, project configuration or private state could not be verified."); }
4041
+ }
4042
+ let autonomyConfirmed = autonomyConsentLocationSafe() ? autonomyConsent.confirmed(autonomyConsentValues(), autonomyConsentTrusted()) : new Set<string>();
4043
+ const pendingPermissionDefaults = new Map(Object.entries(defaultSources).filter(([peer, value]) => value.mode === "never-ask" && !autonomyConfirmed.has(peer)).map(([peer, value]) => [peer, value.source]));
3859
4044
  const permissionChanges = new Map<string, Promise<void>>();
3860
- const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
4045
+ const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" && !autonomyConfirmed.has(peer) ? "ask" : mode]));
3861
4046
  for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
3862
4047
  const changedPermissionDefaults = new Set<string>();
4048
+ function refreshAutonomyConsent(candidate: HubConfig = config): void {
4049
+ const next = autonomyConsentLocationSafe() ? autonomyConsent.confirmed(autonomyConsentValues(candidate), autonomyConsentTrusted()) : new Set<string>();
4050
+ for (const peer of new Set([...next, ...autonomyConfirmed])) if (next.has(peer) !== autonomyConfirmed.has(peer)) changedPermissionDefaults.add(peer);
4051
+ autonomyConfirmed = next;
4052
+ }
3863
4053
  const applyPendingPermissionDefault = (peer: string): void => {
4054
+ refreshAutonomyConsent();
3864
4055
  if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
3865
4056
  changedPermissionDefaults.delete(peer);
3866
4057
  const mode = config.permission_modes[peer] ?? "ask";
3867
4058
  const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
3868
- permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
4059
+ permissionModes.set(peer, mode === "never-ask" && !autonomyConfirmed.has(peer) ? "ask" : mode);
3869
4060
  permissionModeSources.set(peer, source);
3870
4061
  pendingPermissionDefaults.delete(peer);
3871
- if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
4062
+ if (mode === "never-ask" && !autonomyConfirmed.has(peer)) pendingPermissionDefaults.set(peer, source);
3872
4063
  if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
3873
4064
  else delete defaultSources[peer];
3874
4065
  }
@@ -3881,7 +4072,62 @@ export async function startDaemon(opts: DaemonOptions) {
3881
4072
  };
3882
4073
  const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
3883
4074
  // #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
3884
- const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
4075
+ // #999 T3, the floor: its facts are read where the classifier runs, per peer, and an unknown one keeps the class asking. The default branch comes from the same
4076
+ // authoritative remote read the task shell uses (never a local origin/HEAD), cached because the gate is synchronous; before the read answers, or when it fails, every push asks.
4077
+ let floorDefaultBranch: string | undefined, floorGeneration = 0;
4078
+ const floorGitRaw = (cwd: string, args: string[]) => { try { return hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: 1000, maxBuffer: 4096 }); } catch { return undefined; } };
4079
+ const floorFacts = (peer: PeerId): FloorFacts | undefined => {
4080
+ if (!actionBudgets.floorOn()) return undefined;
4081
+ let root: string;
4082
+ try { root = realPath(executionRoot(peer)); } catch { return undefined; }
4083
+ // One classification gets a bounded amount of synchronous work (`floorReads`): every authority read goes through it, the project origin included, so one shared cap, memo and deadline
4084
+ // cover all of them. Past the bound a fact is unknown, and an unknown fact keeps the call asking.
4085
+ const FLOOR_GIT_CALLS = 16, FLOOR_GIT_MS = 2500;
4086
+ const reads = floorReads(FLOOR_GIT_CALLS, FLOOR_GIT_MS);
4087
+ const floorGit = (cwd: string, args: string[]): ReturnType<typeof floorGitRaw> => reads.read(`git\0${cwd}\0${args.join("\0")}`, () => floorGitRaw(cwd, args));
4088
+ const floorOrigin = (): string | undefined => reads.read(`origin\0${root}`, () => githubOriginRepository(root));
4089
+ return {
4090
+ ...(floorDefaultBranch ? { defaultBranch: floorDefaultBranch } : {}),
4091
+ ...(shellProjectRepository ? { repository: shellProjectRepository } : {}),
4092
+ // Proven only: the directory's own top level is the peer's execution root and that checkout's origin is the project's repository.
4093
+ inOwnRepository: cwd => {
4094
+ try {
4095
+ const top = floorGit(cwd, ["rev-parse", "--show-toplevel"]);
4096
+ if (!top || top.status !== 0 || typeof top.stdout !== "string" || realPath(top.stdout.trim()) !== root) return undefined;
4097
+ return !!shellProjectRepository && floorOrigin() === shellProjectRepository;
4098
+ } catch { return undefined; }
4099
+ },
4100
+ refIsTag: (name, cwd) => { const found = floorGit(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${name}`]); return found === undefined || found.status === null ? undefined : found.status === 0 ? true : found.status === 1 ? false : undefined; },
4101
+ // The effective push destinations, not the fetch URL: pushurl and url.<base>.pushInsteadOf can send `git push origin` to another repository, and push.followTags,
4102
+ // push.pushOption, push.recurseSubmodules and a remote.origin.push or mirror setting add refs, tags or submodule pushes to a plain branch push. `--all` because a remote can have several push
4103
+ // URLs and git pushes to every one of them: each must name the project's own repository. Any of those settings present, or any read that fails, is not proof.
4104
+ pushIsOwn: cwd => {
4105
+ const added = floorGit(cwd, ["config", "--get-regexp", "^(push\\.(followtags|pushoption|recursesubmodules)|remote\\.origin\\.(push|mirror|pushurl))$"]);
4106
+ if (added === undefined || added.status !== 1) return added?.status === 0 ? false : undefined;
4107
+ const urls = floorGit(cwd, ["remote", "get-url", "--push", "--all", "origin"]);
4108
+ if (urls === undefined || urls.status !== 0 || typeof urls.stdout !== "string") return undefined;
4109
+ const lines = urls.stdout.split("\n").map(line => line.trim()).filter(Boolean);
4110
+ return !!shellProjectRepository && lines.length > 0 && lines.every(line => githubRemoteRepository(line) === shellProjectRepository);
4111
+ },
4112
+ // gh's default repository. A peer whose commands the hub builds the environment for (Pi and the local worker: `trust.hubSandboxed`) is judged on the facts the hub can read: only origin, no
4113
+ // `gh repo set-default` marker naming another repository, no GH_REPO, GH_HOST or GH_ENTERPRISE_TOKEN in the hub's own environment. For every other peer (Claude, Codex, Kimi, Qwen, Jcode, agy)
4114
+ // the hub cannot see the environment its shell runs in, and a GH_REPO exported there would redirect an unqualified call: such a call is never proven own, so it asks (foreign-repo-write)
4115
+ // and only an explicit own `--repo`, `-R` or pull request URL runs.
4116
+ ghDefaultIsOwn: cwd => {
4117
+ if (definitionOf(peer)?.trust.hubSandboxed !== true) return false;
4118
+ if (process.env.GH_REPO || process.env.GH_HOST || process.env.GH_ENTERPRISE_TOKEN) return false;
4119
+ const remotes = floorGit(cwd, ["remote"]);
4120
+ if (remotes === undefined || remotes.status !== 0 || typeof remotes.stdout !== "string" || remotes.stdout.trim() !== "origin") return remotes?.status === 0 ? false : undefined;
4121
+ const resolved = floorGit(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]);
4122
+ if (resolved === undefined) return undefined;
4123
+ if (resolved.status === 1) return true;
4124
+ if (resolved.status !== 0 || typeof resolved.stdout !== "string") return undefined;
4125
+ return resolved.stdout.trim().split("\n").every(line => line.trim().split(/\s+/)[1] === "base");
4126
+ },
4127
+ currentBranch: cwd => { const found = floorGit(cwd, ["symbolic-ref", "-q", "--short", "HEAD"]); return found?.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? found.stdout.trim() : undefined; },
4128
+ };
4129
+ };
4130
+ const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot, floor: floorFacts,
3885
4131
  task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
3886
4132
  nameable: text => tasks.nameable(text) }, peer);
3887
4133
  // A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
@@ -3918,6 +4164,14 @@ export async function startDaemon(opts: DaemonOptions) {
3918
4164
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
3919
4165
  const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
3920
4166
  let stopping = false;
4167
+ /** Read the default branch for the floor. A newer policy or a stop makes an older read's answer void, and a failed read leaves the branch unknown (every push asks). */
4168
+ const refreshFloorFacts = async (): Promise<void> => {
4169
+ const generation = ++floorGeneration;
4170
+ if (config.action_policy?.floor !== true || !shellProjectRepository) { floorDefaultBranch = undefined; return; }
4171
+ const found = await (opts.shellDefaultBranchProbe ?? readTaskShellDefaultBranch)(opts.cwd, hostShellEnvironment({ PATH: hostShellPath(opts.cwd, opts.cwd) ?? "" }), () => !stopping && generation === floorGeneration, opts.cwd, shellProjectRepository).catch(() => undefined);
4172
+ if (generation === floorGeneration) floorDefaultBranch = found;
4173
+ };
4174
+ void refreshFloorFacts();
3921
4175
  const roomInvocations: RoomInvocations = new RoomInvocations({ bus, ledger: invocations, shares: () => readShare(config.invocation?.share), identity: (peer, sender) => roomChat.invocationIdentity(peer, sender),
3922
4176
  target: async (input, request) => { await invokeCapability(input.remotePeer, { peer: input.envelope.to, capability: request.capability, args: request.args }, false, () => { if (!input.current()) throw new PreEffectToolRefusal("remote invocation authority changed"); }, { ...input, request }); },
3923
4177
  safeResult: async (body, current) => {
@@ -4014,6 +4268,7 @@ export async function startDaemon(opts: DaemonOptions) {
4014
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
4015
4269
  if (tasks.queuePressureRechecksPending() !== 0) return false;
4016
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;
4017
4272
  if (!recoveryPeerSnapshot) return true;
4018
4273
  const current = recoveryPeers();
4019
4274
  return recoveryPeerSnapshot.every((saved) => {
@@ -4163,7 +4418,11 @@ export async function startDaemon(opts: DaemonOptions) {
4163
4418
  return row;
4164
4419
  });
4165
4420
  const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
4166
- 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])) } : {}) };
4167
4426
  };
4168
4427
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
4169
4428
  const integrityAs = (expected: unknown) => {
@@ -4331,6 +4590,7 @@ export async function startDaemon(opts: DaemonOptions) {
4331
4590
  board.onChange = (task, entry) => {
4332
4591
  try { taskChanged?.(task, entry); }
4333
4592
  finally {
4593
+ try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4334
4594
  try { settleSupersededDeliveries(); }
4335
4595
  finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4336
4596
  }
@@ -4826,7 +5086,8 @@ export async function startDaemon(opts: DaemonOptions) {
4826
5086
  } else {
4827
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)}`);
4828
5088
  const env = e.env;
4829
- 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 } : {}) });
4830
5091
  }
4831
5092
  if (!stopping) writeStatus();
4832
5093
  });
@@ -6443,6 +6704,7 @@ export async function startDaemon(opts: DaemonOptions) {
6443
6704
  return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
6444
6705
  }
6445
6706
  function declaredPaths(value: unknown, prefix = ""): string[] {
6707
+ if (prefix === "_autonomy" || prefix.startsWith("_autonomy.")) return [];
6446
6708
  // Retired dictionaries never enter active publication; hub ids remain opaque keys.
6447
6709
  if (prefix === "routing.targets" || prefix === "routing.routes") return [];
6448
6710
  if (prefix === "routing.hub_routes") return [prefix];
@@ -6627,7 +6889,7 @@ export async function startDaemon(opts: DaemonOptions) {
6627
6889
  if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
6628
6890
  putConfig(config, change.key, change.after);
6629
6891
  }
6630
- if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
6892
+ if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) { actionBudgets.policyChanged(); void refreshFloorFacts(); }
6631
6893
  if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
6632
6894
  for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
6633
6895
  for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
@@ -6636,6 +6898,7 @@ export async function startDaemon(opts: DaemonOptions) {
6636
6898
  config.retired = prepared.running.retired;
6637
6899
  for (const line of config.retired ?? []) if (!priorRetired.has(line)) log(`config: ${line}`);
6638
6900
  config.permission_default_sources = candidate.permission_default_sources;
6901
+ refreshAutonomyConsent(prepared.fileConfig);
6639
6902
  for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
6640
6903
  if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
6641
6904
  if (changes.some(change => change.key === "schedules" || change.key.startsWith("schedules.") || change.key === "folder_triggers" || change.key.startsWith("folder_triggers."))) rescheduleSchedules();
@@ -6774,7 +7037,10 @@ export async function startDaemon(opts: DaemonOptions) {
6774
7037
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
6775
7038
  rosterChanging = peer;
6776
7039
  if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
6777
- else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), {preserveUndo:authority==="conductor"});
7040
+ else {
7041
+ const written = writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), {preserveUndo:authority==="conductor"});
7042
+ if (written && authority !== "conductor") autonomyUndoWitness.clear();
7043
+ }
6778
7044
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
6779
7045
  config.roster = next;
6780
7046
  settlePlanner(); // #455: an automatic planner follows the roster
@@ -6795,9 +7061,10 @@ export async function startDaemon(opts: DaemonOptions) {
6795
7061
  checkOperational(authority);
6796
7062
  const mode = config.permission_modes[peer] ?? "ask";
6797
7063
  permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
7064
+ if (mode === "never-ask" && autonomyConfirmed.has(peer)) permissionModes.set(peer, mode);
6798
7065
  permissionModeSources.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
6799
7066
  pendingPermissionDefaults.delete(peer);
6800
- if (mode === "never-ask") pendingPermissionDefaults.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
7067
+ if (mode === "never-ask" && !autonomyConfirmed.has(peer)) pendingPermissionDefaults.set(peer, defaultSources[peer]?.source ?? "embedded configuration");
6801
7068
  const handoffCurrent = authority === "conductor" ? operationalContext.getStore()!.current : undefined;
6802
7069
  handoff = await tasks.reassignForRoster(peer, handoffCurrent);
6803
7070
  } else {
@@ -6878,6 +7145,7 @@ export async function startDaemon(opts: DaemonOptions) {
6878
7145
  const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
6879
7146
  if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
6880
7147
  saved = true;
7148
+ if (authority !== "conductor") autonomyUndoWitness.clear();
6881
7149
  const result = await reloadConfig(authority, def.key);
6882
7150
  if (result.ok !== true) return { ...result, saved: true };
6883
7151
  recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
@@ -6887,6 +7155,7 @@ export async function startDaemon(opts: DaemonOptions) {
6887
7155
  const from = settingScalar(valueAt(storedConfig(), def.path));
6888
7156
  if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")), {preserveUndo:authority==="conductor"})) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
6889
7157
  saved = true;
7158
+ if (authority !== "conductor") autonomyUndoWitness.clear();
6890
7159
  const result = await reloadConfig(authority, def.path.join("."));
6891
7160
  if (result.ok !== true) return { ...result, saved: true };
6892
7161
  const to = settingScalar(valueAt(storedConfig(), def.path));
@@ -6902,6 +7171,132 @@ export async function startDaemon(opts: DaemonOptions) {
6902
7171
  }
6903
7172
  }
6904
7173
 
7174
+ function autonomyPeers(): AutonomyPeer[] {
7175
+ return SUPPORTED_PEERS.map(id => {
7176
+ const setting = settingDef(`permission_modes.${id}`), definition = definitionOf(id), owner = bus.peers.get(id);
7177
+ const lockedMode = typeof setting !== "string" && setting.locked ? setting.values?.[0] : undefined;
7178
+ const form = owner?.form ?? startMode(id);
7179
+ const unmanaged = definition?.permission.unmanaged || definition?.permission.unmanagedForms?.includes(owner ? owner.form ?? "tui" : form);
7180
+ return { id, kind: definition?.kind ?? id, unmanaged: !!definition?.permission.unmanaged, ...(lockedMode ? { lockedMode } : {}), form,
7181
+ permissionMode: unmanaged && !lockedMode ? "unmanaged" : permissionDisplay(id, owner), actionEnforcement: actionEnforcement(id) };
7182
+ });
7183
+ }
7184
+ function autonomyOperations(environment: AutonomyEnvironment) {
7185
+ return planAutonomy(environment, key => {
7186
+ const def = settingDef(key);
7187
+ return typeof def === "string" || def.store !== "config" ? undefined : { store: def.store, values: def.values, locked: def.locked };
7188
+ }, autonomyPeers()).map(operation => {
7189
+ const def = settingDef(operation.key);
7190
+ if (typeof def !== "string") checkSettingValue(def, operation.value);
7191
+ return operation;
7192
+ });
7193
+ }
7194
+ async function autonomyPublish(keys: string[]): Promise<Record<string, unknown>> {
7195
+ for (const key of keys) {
7196
+ const result = await reloadConfig("terminal", key);
7197
+ if (result.ok !== true) return { ...result, saved: true };
7198
+ }
7199
+ return { ok: true };
7200
+ }
7201
+ async function autonomyUndo(authority: SettingAuthority, confirm: unknown, identity: UndoIdentity): Promise<Record<string, unknown>> {
7202
+ if (authority !== "terminal" || confirm !== "autonomy") return { ok: false, error: "undoing project autonomy needs the person's terminal and the autonomy confirmation" };
7203
+ let saved = false;
7204
+ try {
7205
+ if (!autonomyConsentLocationSafe() || !autonomyUndoWitness.confirmed(autonomyWitnessValues("undo", identity.undoDigest), true).has("undo")) {
7206
+ const removed = await autonomyCommand("off", Object.keys(autonomyPreset().environments)[0], true);
7207
+ return removed.ok === true ? { ...removed, text: "Preset undo witness did not verify; fell back to removing the preset keys and clearing retained consent." } : removed;
7208
+ }
7209
+ const keys = autonomyUndoKeys(opts.stateDir);
7210
+ const restored = undoSetting(opts.cwd, opts.stateDir, scratch => { configWriteCheck(scratch, "terminal", AUTONOMY_UNDO.key); for (const key of keys) configWriteCheck(scratch, "terminal", key); }, undefined, identity);
7211
+ saved = true;
7212
+ try { if (autonomyConsentLocationSafe()) autonomyConsent.clear(); } finally { refreshAutonomyConsent(); }
7213
+ ownAutonomyMetadata(restored.ownership); autonomyUndoWitness.clear();
7214
+ const result = await autonomyPublish(keys);
7215
+ if (result.ok !== true) return result;
7216
+ recordSetting(AUTONOMY_UNDO.key, "preset change", "previous configuration", "terminal", true);
7217
+ return { ok: true, text: "Project autonomy change undone; live keys apply now, permission and start defaults at the next peer start." };
7218
+ } catch (error) {
7219
+ for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
7220
+ return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
7221
+ }
7222
+ }
7223
+ let autonomyPreview: { token: string; environment: string; fingerprint: string; revision: number; operations: string } | undefined;
7224
+ async function autonomyCommand(action: unknown, environment: unknown, confirmed: unknown, confirmation?: unknown): Promise<Record<string, unknown>> {
7225
+ if (stopping || recoveryActive() || rosterChanging) return { ok: false, error: "recovery, shutdown or roster change is holding autonomy" };
7226
+ if (typeof action !== "string" || typeof environment !== "string" || !Object.entries(autonomyPreset().environments).some(([name, row]) => name === environment || row.aliases.includes(environment)) || !["preview", "on", "off", "status"].includes(action)) return { ok: false, error: "invalid autonomy command or environment" };
7227
+ let saved = false;
7228
+ try {
7229
+ const env = environment as AutonomyEnvironment;
7230
+ const timing = (key: string) => { const def = settingDef(key); return typeof def !== "string" && def.applies === "next use" ? "next peer start" : typeof def !== "string" && def.applies === "restart" ? "next hub start" : "live"; };
7231
+ if (action === "status") {
7232
+ refreshAutonomyConsent();
7233
+ for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
7234
+ const terminal = opener.available();
7235
+ const peers = autonomyPeers();
7236
+ const rows = autonomyStatus({ environment: env, peers, terminalOpener: terminal.ok ? { available: true, via: terminal.via } : { available: false, reason: terminal.why },
7237
+ floorEnforcement: Object.fromEntries(peers.map(peer => [peer.id, floorEnforcement(config.action_policy, peer.actionEnforcement ?? { enforced: false })])) });
7238
+ for (const row of rows) if (pendingPermissionDefaults.has(row.id)) row.missing.push("This never-ask default has no matching retained consent; confirm it in ahub console or run ahub autonomy on again.");
7239
+ return { ok: true, status: { environment: env, consentedPeers: [...autonomyConfirmed], peers: rows }, lines: [`Project autonomy, environment: ${env}`, ...rows.flatMap(row => [`${row.id}: ${row.effectiveMode}; preset ${row.nextMode ?? "unmanaged"} at ${row.applies}`, ...row.floor.map(floor => ` ${floor.class}: ${floor.status}; ${floor.reason}`), ...row.missing.map(reason => ` missing: ${reason}`)])] };
7240
+ }
7241
+ const operations = action === "off" ? autonomyRemovalKeys(autonomyPeers()).map(key => ({ key, value: null, store: "config" as const })) : autonomyOperations(env);
7242
+ if (action === "preview") {
7243
+ autonomyPreview = { token: randomUUID(), environment: env, fingerprint: configFingerprint(configSourceTexts(opts.cwd)), revision: configRevision, operations: JSON.stringify(operations) };
7244
+ return { ok: true, confirmation: autonomyPreview.token, lines: ["Project autonomy: approval prompts change; sandbox, deny, PII and trust rules remain. Check autonomy status for the current floor enforcement and missing guards.", autonomyPreset().about, ...operations.map(operation => `${operation.key}: ${settingText(operation.value)} (${timing(operation.key)})`), "Unmanaged native clients and missing structural guards are reported by autonomy status; the preset cannot enforce those paths."] };
7245
+ }
7246
+ if (action === "on" && confirmed !== true) return { ok: false, error: "project autonomy needs one person confirmation; use ahub autonomy on [--yes]" };
7247
+ if (action === "on") {
7248
+ if (!autonomyPreview || confirmation !== autonomyPreview.token || env !== autonomyPreview.environment || autonomyPreview.revision !== configRevision || autonomyPreview.operations !== JSON.stringify(operations) || autonomyPreview.fingerprint !== configFingerprint(configSourceTexts(opts.cwd))) return { ok: false, error: "autonomy preview is stale or missing; run ahub autonomy on again to review the current values" };
7249
+ autonomyPreview = undefined;
7250
+ }
7251
+ // Off has a fixed shipped removal scope, independent of environment and untrusted ownership metadata.
7252
+ const allowed = operations;
7253
+ if (action === "on" && !autonomyConsentLocationSafe()) return { ok: false, error: "retained autonomy consent requires private hub state outside the project and execution roots" };
7254
+ if (action === "off") { try { if (autonomyConsentLocationSafe()) autonomyConsent.clear(); } finally { refreshAutonomyConsent(); } }
7255
+ refreshAutonomyConsent();
7256
+ const renewConsentUndo = action === "on" && operations.some(operation => operation.key.startsWith("permission_modes.") && operation.value === "never-ask" && !autonomyConfirmed.has(operation.key.slice("permission_modes.".length)));
7257
+ const result = writeAutonomyPreset(opts.cwd, opts.stateDir, action === "on", allowed, (scratch, keys) => { configWriteCheck(scratch, "terminal", AUTONOMY_UNDO.key); for (const key of keys) configWriteCheck(scratch, "terminal", key); }, metadata => {
7258
+ try {
7259
+ const digest = autonomyOwnershipDigest(metadata);
7260
+ return !!digest && autonomyConsentLocationSafe() && autonomyOwnershipWitness.confirmed(autonomyWitnessValues("ownership", digest), true).has("ownership");
7261
+ } catch { return false; }
7262
+ }, renewConsentUndo);
7263
+ saved = result.changed;
7264
+ if (result.changed) {
7265
+ if (action === "on") {
7266
+ ownAutonomyMetadata(result.ownership);
7267
+ if (result.undo) autonomyUndoWitness.confirm(autonomyWitnessValues("undo", result.undo.undoDigest));
7268
+ else autonomyUndoWitness.clear();
7269
+ } else {
7270
+ // Private restoration metadata cannot veto the person's off switch either.
7271
+ try { ownAutonomyMetadata(null); } catch { /* Invalid state is never restoration authority. */ }
7272
+ try { if (result.undo) autonomyUndoWitness.confirm(autonomyWitnessValues("undo", result.undo.undoDigest)); else autonomyUndoWitness.clear(); } catch { /* Undo will use the safe removal fallback. */ }
7273
+ }
7274
+ const published = await autonomyPublish(result.keys);
7275
+ if (published.ok !== true) return published;
7276
+ recordSetting(AUTONOMY_UNDO.key, action === "on" ? "off" : "on", action, "terminal", false);
7277
+ }
7278
+ if (action === "off" && autonomyConsentLocationSafe()) { try { autonomyOwnershipWitness.clear(); } catch { /* Off does not depend on restoration metadata. */ } }
7279
+ if (action === "on") {
7280
+ if (!autonomyConsentTrusted()) throw new Error("config.local.json trust changed; retained consent was not issued");
7281
+ const fileConfig = loadConfig(opts.cwd, undefined, undefined, externalDefinitions);
7282
+ validateEffectiveConfig(fileConfig, adapterCatalog);
7283
+ const current = autonomyConsentValues(fileConfig);
7284
+ const retained = autonomyConsent.confirmed(current, true);
7285
+ const approved: AutonomyConsentValues = Object.fromEntries([...retained].map(peer => [peer, current[peer]!]));
7286
+ for (const operation of operations) if (operation.key.startsWith("permission_modes.") && operation.value === "never-ask") {
7287
+ const peer = operation.key.slice("permission_modes.".length);
7288
+ if (current[peer]) approved[peer] = current[peer]!;
7289
+ }
7290
+ try { autonomyConsent.confirm(approved); } finally { refreshAutonomyConsent(); }
7291
+ }
7292
+ for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
7293
+ return { ok: true, lines: [`Project autonomy ${action}: ${result.changed ? "saved" : "unchanged"}. ahub settings undo restores the complete last write.`, ...result.keys.map(key => `${key}: ${timing(key)}`), ...result.preserved.map(key => `${key}: kept the later manual edit`)] };
7294
+ } catch (error) {
7295
+ if (action === "off") for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
7296
+ return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
7297
+ }
7298
+ }
7299
+
6905
7300
  async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
6906
7301
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
6907
7302
  let reason: string | undefined;
@@ -6915,6 +7310,7 @@ export async function startDaemon(opts: DaemonOptions) {
6915
7310
  }
6916
7311
  const def = undoableDef(identity?.key);
6917
7312
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
7313
+ if (def === AUTONOMY_UNDO) return autonomyUndo(authority, confirm, identity);
6918
7314
  if (def.managed && authority !== "terminal") return { ok: false, error: `${def.key} was written by ahub learn accept: put it back with ahub settings undo in a terminal` };
6919
7315
  if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
6920
7316
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
@@ -6971,6 +7367,7 @@ export async function startDaemon(opts: DaemonOptions) {
6971
7367
  function settingsUndoPreview(): Record<string, unknown> {
6972
7368
  const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
6973
7369
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
7370
+ if (def === AUTONOMY_UNDO) return { ok: true, ...identity, lines: ["Undo the complete project autonomy change when its private witness verifies; otherwise remove the fixed preset keys and clear consent. Type autonomy to confirm; modes at peer start and existing live keys keep their apply timing."] };
6974
7371
  if (def.managed) return { ok: true, ...identity, lines: [`Undo ${def.key}: put back the previous machine-local file (the review checklist ahub learn accept changed; its lines are not shown here)`, "The saved previous machine-local file is revalidated before writing."] };
6975
7372
  const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
6976
7373
  if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
@@ -7130,6 +7527,7 @@ export async function startDaemon(opts: DaemonOptions) {
7130
7527
  ...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
7131
7528
  ...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
7132
7529
  ...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
7530
+ ...(tasks.studiesPending() !== 0 ? ["a research campaign admission in flight"] : []),
7133
7531
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
7134
7532
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
7135
7533
  ...(releasing ? ["ready tasks being released"] : []),
@@ -7171,6 +7569,74 @@ export async function startDaemon(opts: DaemonOptions) {
7171
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";
7172
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";
7173
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";
7604
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("project_autonomy")) {
7605
+ let needsAutonomy = autonomyConfirmed.size > 0;
7606
+ try {
7607
+ const texts = configSourceTexts(opts.cwd);
7608
+ needsAutonomy ||= CONFIG_FILES.some(name => texts[name] !== null && Object.hasOwn(JSON.parse(texts[name]!), "_autonomy"));
7609
+ } catch { return "project autonomy compatibility is unknown; configuration could not be read"; }
7610
+ if (needsAutonomy) return "target lacks project_autonomy; project preset metadata and retained mode consent require a supporting daemon";
7611
+ }
7612
+ // #999 T3: a target without the floor key rejects `action_policy.floor` as an unknown key, so the project would fail to load there. The check is the key's presence in the project's file, true or false:
7613
+ // `floor: false` is just as unknown to an older target, and a disabled policy still names it.
7614
+ // The raw files decide, not the effective config: a shared `config.json` that names `action_policy.floor` is ignored (the key is machine-local) but an older target's parser rejects it
7615
+ // before that rule runs, and a `floor: false` is normalized away. Unreadable files are unknown, never absent.
7616
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("action_policy_floor")) {
7617
+ let namesFloor = config.action_policy?.floor !== undefined;
7618
+ try {
7619
+ const texts = configSourceTexts(opts.cwd);
7620
+ namesFloor ||= CONFIG_FILES.some(name => {
7621
+ const policy = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).action_policy;
7622
+ return !!policy && typeof policy === "object" && Object.hasOwn(policy, "floor");
7623
+ });
7624
+ } catch { return "action_policy_floor compatibility is unknown; configuration could not be read"; }
7625
+ if (namesFloor) return "target lacks action_policy_floor; the project's action_policy.floor cannot be loaded by that daemon";
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
+ }
7174
7640
  return undefined;
7175
7641
  };
7176
7642
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
@@ -7453,12 +7919,22 @@ export async function startDaemon(opts: DaemonOptions) {
7453
7919
  c.authed = true;
7454
7920
  c.role = msg.role;
7455
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
+ }
7456
7931
  else if (c.role === "tools") {
7457
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.
7458
7933
  c.peer = String(msg.peer ?? "");
7459
7934
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
7460
7935
  if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
7461
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;
7462
7938
  if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
7463
7939
  const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
7464
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");
@@ -7525,7 +8001,9 @@ export async function startDaemon(opts: DaemonOptions) {
7525
8001
  if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
7526
8002
  });
7527
8003
  }
7528
- 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 } : {}) });
7529
8007
  }
7530
8008
  const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
7531
8009
  && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
@@ -7533,6 +8011,11 @@ export async function startDaemon(opts: DaemonOptions) {
7533
8011
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
7534
8012
  if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
7535
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
+ }
7536
8019
  switch (msg.t) {
7537
8020
  case "request_cancel": {
7538
8021
  if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
@@ -7905,6 +8388,8 @@ export async function startDaemon(opts: DaemonOptions) {
7905
8388
  }
7906
8389
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
7907
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)
7908
8393
  try { bus.checkRemotePublication(env); }
7909
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" }); }
7910
8395
  const offRoster = bus.offRosterRecipients(env);
@@ -8029,7 +8514,7 @@ export async function startDaemon(opts: DaemonOptions) {
8029
8514
  const mode = permissionMode(peer);
8030
8515
  if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
8031
8516
  const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
8032
- const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
8517
+ const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root, floorFacts(peer)) : [];
8033
8518
  return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
8034
8519
  }
8035
8520
  let nativeClaude = false, nativeTranscript: string | undefined;
@@ -8059,7 +8544,7 @@ export async function startDaemon(opts: DaemonOptions) {
8059
8544
  if (permission !== undefined && nativePeer) claudeLaunchProof.bindGeneration(peer, nativePeer.sessionGeneration);
8060
8545
  const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
8061
8546
  // Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
8062
- const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
8547
+ const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd, floorFacts(peer)) : [];
8063
8548
  const plannerOnly = !!declarations[peer] && definitionOf(peer)?.transport === "claude-channel" && isPlannerOnly(config.roles[peer] ?? []);
8064
8549
  // Older hooks do not know mutationGrants; the existing permission field must also withhold their mutation grant.
8065
8550
  const plannerMutation = plannerOnly && !["Read", "LS", "Glob", "Grep"].includes(tool);
@@ -8424,6 +8909,10 @@ export async function startDaemon(opts: DaemonOptions) {
8424
8909
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding task role changes" });
8425
8910
  try { const task = tasks.setRole(c.role === "console" ? USER : c.peer!, msg.id, msg.role); return void reply({ ok: true, text: `task #${task.id}: catalog role ${task.role ?? "none"}` }); }
8426
8911
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
8912
+ case "autonomy":
8913
+ if (c.role !== "console") return void reply({ ok: false, error: "autonomy is a person-only command; run ahub autonomy in a person's terminal" });
8914
+ void autonomyCommand(msg.action, msg.environment, msg.confirmed, msg.confirmation).then(reply, () => reply({ ok: false, error: "project autonomy could not be changed" }));
8915
+ return;
8427
8916
  case "settings_get":
8428
8917
  if (c.role !== "console") {
8429
8918
  try { const current = publicReadCurrent(sock); current(); const view = publicSettingsView(); current(); return void reply({ ok: true, ...view }); }
@@ -8519,6 +9008,8 @@ export async function startDaemon(opts: DaemonOptions) {
8519
9008
  try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
8520
9009
  catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
8521
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" });
8522
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" });
8523
9014
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
8524
9015
  if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
@@ -8690,6 +9181,32 @@ export async function startDaemon(opts: DaemonOptions) {
8690
9181
  // Let the acknowledgement flush before closing the control listener.
8691
9182
  setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
8692
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
+ }
8693
9210
  default:
8694
9211
  // A newer CLI talking to an older hub must get an answer, not wait forever.
8695
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)` });
@@ -8708,6 +9225,7 @@ export async function startDaemon(opts: DaemonOptions) {
8708
9225
  stopping = true;
8709
9226
  roomCommands.stop();
8710
9227
  tasks.stopQueuePressureRechecks();
9228
+ const studiesStopped = tasks.stopStudies();
8711
9229
  let implementerShellCleanupFailed = false;
8712
9230
  const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
8713
9231
  terminalLaunches.clear();
@@ -8730,11 +9248,11 @@ export async function startDaemon(opts: DaemonOptions) {
8730
9248
  for (const pending of checkpointWaits.values()) pending.done(undefined);
8731
9249
  for (const p of permissions.values()) p.done(undefined);
8732
9250
  try {
8733
- // 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
8734
- // its own timeout) before the peers stop, the board closes and the store closes, so no pass runs against a closed database.
8735
- 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) {
8736
9254
  let timer: ReturnType<typeof setTimeout> | undefined;
8737
- 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?.(); })]);
8738
9256
  clearTimeout(timer);
8739
9257
  }
8740
9258
  await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
@@ -8804,6 +9322,7 @@ export async function startDaemon(opts: DaemonOptions) {
8804
9322
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
8805
9323
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
8806
9324
  ready = true;
9325
+ handoffCatchUp();
8807
9326
  tasks.scheduleQueuePressureRecheck();
8808
9327
  for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
8809
9328
  const path = join(opts.cwd, ".agenthub", name);