@staix/agent-hub 0.22.0 → 0.23.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 (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +4 -1
  6. package/docs/agent-notes/daemon.md +3 -2
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/models.md +2 -1
  9. package/docs/agent-notes/tasks.md +1 -0
  10. package/docs/agent-notes/tests.md +1 -1
  11. package/docs/dogfood.md +7 -1
  12. package/docs/operations.md +147 -6
  13. package/docs/profiles.md +28 -3
  14. package/docs/quickstart.md +5 -1
  15. package/docs/security.md +7 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +66 -4
  17. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  18. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  19. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  20. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  21. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  22. package/docs/verified.json +15 -15
  23. package/package.json +1 -1
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  26. package/plugins/agent-hub/server.js +559 -115
  27. package/src/adapters/acp.ts +48 -2
  28. package/src/adapters/claude-channel.ts +6 -4
  29. package/src/adapters/codex-appserver.ts +19 -0
  30. package/src/adapters/jcode.ts +1 -1
  31. package/src/adapters/local-worker.ts +3 -3
  32. package/src/adapters/native-capabilities.ts +50 -0
  33. package/src/adapters/registry-types.ts +3 -10
  34. package/src/cli/capabilities-output.ts +30 -0
  35. package/src/cli/console-state.ts +8 -5
  36. package/src/cli/console.ts +27 -4
  37. package/src/cli/help.ts +2 -1
  38. package/src/cli/identity.ts +1 -0
  39. package/src/cli/main.ts +8 -0
  40. package/src/hub/board.ts +8 -2
  41. package/src/hub/bus.ts +100 -8
  42. package/src/hub/capabilities.ts +215 -0
  43. package/src/hub/config-apply.ts +6 -3
  44. package/src/hub/config-trust.ts +1 -0
  45. package/src/hub/control-client.ts +41 -8
  46. package/src/hub/daemon.ts +376 -20
  47. package/src/hub/deferred-hub.ts +2 -2
  48. package/src/hub/delivery-hints.ts +2 -2
  49. package/src/hub/delivery-journal.ts +2 -4
  50. package/src/hub/envelope.ts +12 -2
  51. package/src/hub/events.ts +2 -0
  52. package/src/hub/hub-tools.ts +16 -1
  53. package/src/hub/inference.ts +10 -5
  54. package/src/hub/invocations.ts +316 -0
  55. package/src/hub/operational-settings.ts +102 -1
  56. package/src/hub/peers.ts +3 -0
  57. package/src/hub/restart.ts +2 -2
  58. package/src/hub/room-invocations.ts +145 -0
  59. package/src/hub/settings.ts +1 -1
  60. package/src/hub/shipped-packs.ts +5 -2
  61. package/src/hub/shipped-roles.ts +13 -0
  62. package/src/hub/tasks.ts +14 -0
  63. package/src/memory/capture.ts +2 -2
  64. package/src/models/relay.ts +20 -9
  65. package/src/rooms/application.ts +67 -2
  66. package/src/rooms/boundary.ts +43 -11
  67. package/src/rooms/service.ts +40 -2
  68. package/templates/checks/analysis.mjs +59 -0
  69. package/templates/checks/fixtures/analysis-expected.json +1 -0
  70. package/templates/checks/fixtures/analysis-input.json +1 -0
  71. package/templates/checks/fixtures/analysis.mjs +7 -0
  72. package/templates/checks/fixtures/ops.json +4 -0
  73. package/templates/checks/fixtures/writing-rules.json +4 -0
  74. package/templates/checks/fixtures/writing.json +5 -0
  75. package/templates/checks/ops.mjs +40 -0
  76. package/templates/checks/writing.mjs +40 -0
  77. package/templates/packs/analysis.pack.json +14 -0
  78. package/templates/packs/ops.pack.json +14 -0
  79. package/templates/packs/writing-pack.pack.json +14 -0
  80. package/templates/roles/NOTICE.md +1 -1
  81. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  82. package/templates/roles/ops/project-manager.md +26 -0
  83. package/templates/roles/writing/editor.md +26 -0
  84. package/templates/roles/writing/style-checker.md +26 -0
  85. package/templates/roles/writing/terminology-checker.md +26 -0
  86. package/templates/roles/writing/translator.md +26 -0
package/src/hub/daemon.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { RoomStoreError } from "../rooms/store.ts";
2
2
  import { RoomCommands, type RoomPreview } from "../rooms/commands.ts";
3
3
  import { queueBackpressurePolicy, type QueueBackpressurePolicy } from "./queue-backpressure.ts";
4
- import { RoomChat } from "../rooms/service.ts";
5
- import { parseRemotePeerId } from "../rooms/application.ts";
4
+ import { RoomChat, type RoomInvocationIngress } from "../rooms/service.ts";
5
+ import { RoomInvocations, roomInvocationHash } from "./room-invocations.ts";
6
+ import { parseRemotePeerId, roomInvocationArgs, encodeRoomInvocationBody, type RoomReadInvocationRequest } from "../rooms/application.ts";
6
7
  import { assertNativeProofPorts } from "../adapters/native-proof-ports.ts";
7
8
  import { externalInstanceCatalog } from "../adapters/external-metadata.ts";
8
9
  import { externalInstanceBinding, sameExternalInstanceBinding, externalInstanceStateDir, readExternalInstanceRecord, writeExternalInstanceRecord, type ExternalInstanceBinding } from "./external-instance.ts";
@@ -81,6 +82,9 @@ import { childEnv } from "./child-process.ts";
81
82
  import { runCheck } from "./checks.ts";
82
83
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
83
84
  import { CONTRADICTION_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, GROOM_FYI_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_FAILURE_SHARE_SETTING, SPLIT_MIN_SETTING, taskOperationalReaders } from "./operational-settings.ts";
85
+ import { CONSOLE_POLL_SETTING, CONSOLE_NOTICE_SETTING, CONSOLE_HISTORY_SETTING, CONSOLE_PENDING_STREAM_SETTING, CONSOLE_ATTENTION_SETTING, type ConsoleOperationalConfig } from "./operational-settings.ts";
86
+ import { MODEL_COOLDOWN_FAILURES_SETTING, MODEL_COOLDOWN_SETTING, MODEL_COOLDOWN_CAP_SETTING, MODEL_TERMINAL_HISTORY_SETTING, modelOperationalReaders, type ModelOperationalConfig } from "./operational-settings.ts";
87
+ import { ACP_TOOL_CEILING_SETTING, ACP_ACTIVITY_BATCH_SETTING, JCODE_STEER_TIMEOUT_SETTING, adapterOperationalReaders, type AdapterTimingConfig } from "./operational-settings.ts";
84
88
  import { BUS_DIGEST_MAX_SETTING, BUS_MAX_ATTEMPTS_SETTING, BUS_NOTES_KEPT_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING, busOperationalReaders, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING } from "./operational-settings.ts";
85
89
  import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
86
90
  import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
@@ -139,7 +143,7 @@ import { Briefs, type BriefTask } from "../memory/brief.ts";
139
143
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
140
144
  import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
141
145
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
142
- import { HUB } from "./envelope.ts";
146
+ import { remoteEnvelope, HUB } from "./envelope.ts";
143
147
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
144
148
  import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
145
149
  import { basename, isAbsolute } from "node:path";
@@ -147,7 +151,9 @@ import { fileURLToPath } from "node:url";
147
151
  import { realPath, hubHome } from "./project.ts";
148
152
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
149
153
  import type { BusEvent, PublishResult } from "./bus.ts";
150
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
154
+ 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";
155
+ import { effectiveClass, listedCapabilities, PublishRefusal, readShare, shareView, validatePublished, type CapabilityManifest } from "./capabilities.ts";
156
+ import { invocationApproval, checkArgs, DEFAULT_TIMEOUT_S, InvocationLedger, invocationRefusal, refusedBy, renderInvocationResult, requestText, type InvocationOutcome, type InvocationRow } from "./invocations.ts";
151
157
  import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, taskShowAllowed, type ConductEvent } from "./conductor.ts";
152
158
  import { SupervisionFeed } from "./supervision.ts";
153
159
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -223,6 +229,12 @@ export interface HubConfig {
223
229
  queue_cap: number;
224
230
  /** #489 H3: accepted delivery policy, read at the next delivery operation without rewriting queued work. */
225
231
  bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number };
232
+ /** #489 H5: accepted timing captured only when a new native adapter is constructed. */
233
+ adapter_timing?: AdapterTimingConfig["adapter_timing"];
234
+ /** #489 H6: relay policy is captured from restart-bound accepted configuration. */
235
+ model_relay?: ModelOperationalConfig["model_relay"];
236
+ /** #489 H7: accepted presentation preferences read by a new console through settings_get. */
237
+ console?: ConsoleOperationalConfig["console"];
226
238
  /** `native` (#258): the hub's own store in ~/.agenthub/memory, on with any project config; `short_ttl_days`: short-term lifetime after approval; `sync_interval_min`: machine sync while a remote is set (default 10, 0 off). */
227
239
  memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
228
240
  roster: PeerRoster;
@@ -277,6 +289,8 @@ export interface HubConfig {
277
289
  recovery: { auto_resume_after_crash: boolean; reconnect_wait_s?: number; unmanaged_reconnect_wait_s?: number; control_timeout_s?: number; shutdown_wait_s?: number; terminal_idle_wait_s?: number };
278
290
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
279
291
  capabilities: Record<string, string[]>;
292
+ /** #445 C1: machine-local invocation settings. `share` is the person's allowlist of native capabilities by peer id, each pinned by digest (`src/hub/capabilities.ts`). */
293
+ invocation?: { share?: Record<string, Record<string, { digest: string; class?: string }>>; /** Seconds a request may wait for its result, 10 to 86400, default 600 (#445 C2). */ timeout_s?: number };
280
294
  /**
281
295
  * How owners of overlapping tasks coordinate (issue #107): "advisory" lets them message each other; "turn-free" has
282
296
  * them work without messages while the hub derives what they need to know. Anything else is advisory.
@@ -303,12 +317,15 @@ export const DEFAULT_CONFIG: HubConfig = {
303
317
  batch_ms: 15_000,
304
318
  queue_cap: 200,
305
319
  bus: { max_attempts: BUS_MAX_ATTEMPTS_SETTING.defaultValue, digest_max: BUS_DIGEST_MAX_SETTING.defaultValue, pull_budget_bytes: BUS_PULL_BUDGET_BYTES_SETTING.defaultValue, notes_kept: BUS_NOTES_KEPT_SETTING.defaultValue, terminal_history_limit: BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue },
320
+ adapter_timing: { acp_tool_ceiling_min: ACP_TOOL_CEILING_SETTING.defaultValue, acp_activity_batch_ms: ACP_ACTIVITY_BATCH_SETTING.defaultValue, jcode_steer_timeout_s: JCODE_STEER_TIMEOUT_SETTING.defaultValue },
306
321
  memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
307
322
  roster: {},
308
323
  roles: DEFAULT_ROLES,
309
324
  conductor: { feed: "own", approval_wait_s: 30 },
310
325
  budget: DEFAULT_BUDGET,
311
326
  context: DEFAULT_CONTEXT,
327
+ model_relay: { cooldown_failures: MODEL_COOLDOWN_FAILURES_SETTING.defaultValue, cooldown_s: MODEL_COOLDOWN_SETTING.defaultValue, cooldown_cap_s: MODEL_COOLDOWN_CAP_SETTING.defaultValue, terminal_history_limit: MODEL_TERMINAL_HISTORY_SETTING.defaultValue },
328
+ console: { poll_ms: CONSOLE_POLL_SETTING.defaultValue, notice_ms: CONSOLE_NOTICE_SETTING.defaultValue, history_limit: CONSOLE_HISTORY_SETTING.defaultValue, pending_stream_limit: CONSOLE_PENDING_STREAM_SETTING.defaultValue, attention_limit: CONSOLE_ATTENTION_SETTING.defaultValue },
312
329
  inference: DEFAULT_INFERENCE,
313
330
  omniroute: DEFAULT_OMNIROUTE,
314
331
  ...nativeDefaults.rpc,
@@ -338,14 +355,14 @@ export const DEFAULT_CONFIG: HubConfig = {
338
355
  export { stateDirFor };
339
356
 
340
357
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
341
- const READ_ONLY_HUB_TOOLS = new Set(["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"]);
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"]);
342
359
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
343
360
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
344
361
 
345
362
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
346
363
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
347
364
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
348
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
365
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
349
366
 
350
367
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
351
368
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
@@ -539,6 +556,9 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
539
556
  },
540
557
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
541
558
  bus: { ...DEFAULT_CONFIG.bus, ...file.bus },
559
+ adapter_timing: { ...DEFAULT_CONFIG.adapter_timing, ...file.adapter_timing },
560
+ model_relay: { ...DEFAULT_CONFIG.model_relay, ...file.model_relay },
561
+ console: { ...DEFAULT_CONFIG.console, ...file.console },
542
562
  task_sweep: taskSweepConfig(file.task_sweep),
543
563
  schedules: schedulesConfig(file.schedules),
544
564
  folder_triggers: foldersConfig(file.folder_triggers),
@@ -631,6 +651,8 @@ export interface DaemonOptions {
631
651
  agentDrift?: boolean | (() => Promise<readonly AgentVersionRow[]>);
632
652
  /** Test seam: the period of the agent drift sweep, default one day. */
633
653
  agentDriftEveryMs?: number;
654
+ /** Test seam: the period of the invocation deadline sweep (#445 C2), default 5 s. */
655
+ invocationSweepMs?: number;
634
656
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
635
657
  bandsHeld?: () => boolean;
636
658
  /** Test seam: the schedules clock and the period of the due check (default Date.now and one minute). */
@@ -676,6 +698,8 @@ interface Client {
676
698
  watch?: () => void;
677
699
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
678
700
  kimiAdmission?: string;
701
+ invocationRequests?: Map<number, AbortController>;
702
+ closed?: true;
679
703
  }
680
704
  type Sock = ServerWebSocket<Client>;
681
705
 
@@ -771,6 +795,7 @@ export async function startDaemon(opts: DaemonOptions) {
771
795
  const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
772
796
  ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
773
797
  const executionRoots = new Map<string, string>();
798
+ const invocationRequestSignal = new AsyncLocalStorage<{ signal: AbortSignal; current: () => boolean }>();
774
799
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
775
800
  const implementerTranscripts = new Map<string, string>();
776
801
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
@@ -1119,6 +1144,9 @@ export async function startDaemon(opts: DaemonOptions) {
1119
1144
  }
1120
1145
  },
1121
1146
  close(sock) {
1147
+ sock.data.closed = true;
1148
+ for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
1149
+ sock.data.invocationRequests?.clear();
1122
1150
  nativeHookLaunches.endOwner(sock);
1123
1151
  consoles.delete(sock);
1124
1152
  operationalLogSubscribers.delete(sock);
@@ -1182,18 +1210,20 @@ export async function startDaemon(opts: DaemonOptions) {
1182
1210
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1183
1211
  let inference: Inference | undefined;
1184
1212
  const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
1213
+ const adapterTiming = adapterOperationalReaders(() => config);
1214
+ const modelTiming = modelOperationalReaders(() => config);
1185
1215
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1186
1216
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1187
1217
  const lost = new Map<PeerId, JournalDelivery[]>();
1188
1218
  if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
1189
1219
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1190
1220
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1191
- const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run"): boolean => {
1221
+ const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
1192
1222
  if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
1193
1223
  const list = config.capabilities[peer];
1194
1224
  return Array.isArray(list) && list.includes(cap);
1195
1225
  };
1196
- const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run"];
1226
+ const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run", "invoke"];
1197
1227
  for (const [peer, list] of Object.entries(config.capabilities)) {
1198
1228
  if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
1199
1229
  else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
@@ -1310,7 +1340,8 @@ export async function startDaemon(opts: DaemonOptions) {
1310
1340
  if (typeof task !== "string" || !/^[1-9][0-9]*$/.test(task) || !Number.isSafeInteger(Number(task))) return true;
1311
1341
  const row = board.get(Number(task)); if (!row || tasks.isPii(row)) return true;
1312
1342
  }
1313
- return env.from === USER ? false : !bus.peers.has(env.from) || !!turns.get(env.from)?.private || contextPii(env.from);
1343
+ const sender = env.from === HUB ? roomInvocations.localActor(env) ?? HUB : env.from;
1344
+ return sender === USER ? false : !bus.peers.has(sender) || !!turns.get(sender)?.private || contextPii(sender);
1314
1345
  },
1315
1346
  doing: (peer, kind, text) => observeDoing(peer, kind, text),
1316
1347
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
@@ -1676,6 +1707,10 @@ export async function startDaemon(opts: DaemonOptions) {
1676
1707
  };
1677
1708
  const inboxPolling = new InboxPolling();
1678
1709
  const board = new Board(join(opts.stateDir, "hub.db"));
1710
+ const invocations = new InvocationLedger(join(opts.stateDir, "hub.db"));
1711
+ startupCleanup.push(() => invocations.close());
1712
+ bus.canRetryInvocation = id => invocations.get(id)?.class === "read" && !invocations.relay(id);
1713
+ bus.onInvocationUncertain = id => { const row = invocations.get(id); if (row && (row.class !== "read" || invocations.relay(id))) finishInvocation(row, "needs_review", { reason: "delivery outcome is uncertain; check it by hand" }); };
1679
1714
  const codeFacts = new CodeFacts();
1680
1715
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1681
1716
  const memorySymbols = new Map<string, MemorySymbolNodes>();
@@ -1745,6 +1780,105 @@ export async function startDaemon(opts: DaemonOptions) {
1745
1780
  if (hasGeneration && native === undefined) return undefined;
1746
1781
  return `${instanceId}:${token}:${String(native ?? "")}`;
1747
1782
  };
1783
+ /**
1784
+ * #445 C1: what a session published about itself, bound to the connection that published it. A reconnect, a restart or going offline drops it, so a list never
1785
+ * outlives the session it described. A peer the hub reads natively (ACP, Codex) never publishes: its own source is the only one.
1786
+ */
1787
+ const publishedCaps = new Map<PeerId, { generation: string; manifest: CapabilityManifest }>();
1788
+ const manifestOf = async (id: PeerId): Promise<CapabilityManifest | undefined> => {
1789
+ const peer = bus.peers.get(id);
1790
+ if (!peer || peer.state === "offline") return undefined;
1791
+ if (peer.capabilityManifest) { try { return await peer.capabilityManifest(); } catch { return undefined; } } // an unanswered native list is no list, never a stale one
1792
+ const held = publishedCaps.get(id);
1793
+ return held && held.generation === connectionGeneration(id) ? held.manifest : undefined;
1794
+ };
1795
+ /**
1796
+ * #445 C2: the one place an invocation ends. It settles the row once (a late answer after a timeout changes nothing), writes the ids-only event, and, when
1797
+ * the caller is still attached, publishes ONE result from the hub to the caller only. The callee's text is sanitized and cut inside renderInvocationResult;
1798
+ * it is never logged, never put in an event and never captured to memory.
1799
+ */
1800
+ const finishInvocation = (row: InvocationRow, outcome: InvocationOutcome, input: { body?: string; reason?: string; quarantine?: true } = {}): void => {
1801
+ const rendered = renderInvocationResult({ target: row.target, capabilityId: row.capability, id: row.id, outcome, ...input });
1802
+ const settled = invocations.settle(row.id, outcome, rendered.bytes);
1803
+ if (!settled) return;
1804
+ if (settled.task !== null) { try { tasks.recordInvocation(settled.task, settled); } catch { log(`invocation ${settled.id}: terminal audit unavailable; ledger retains outcome`); } }
1805
+ if (invocations.relay(settled.id)?.direction === "outbound") invocations.updateRelay(settled.id, outcome === "needs_review" ? "needs_review" : "completed");
1806
+ const relayed = !input.quarantine && roomInvocations.result(settled, outcome, input);
1807
+ bus.releaseInvocation(settled.id);
1808
+ event({ type: "invocation", id: settled.id, caller: settled.caller, target: settled.target, capability: settled.capability, class: settled.class, outcome, ...(settled.task === null ? {} : { task: settled.task }), ...(rendered.bytes ? { bytes: rendered.bytes } : {}) });
1809
+ if (relayed || input.quarantine) return;
1810
+ if (!bus.peers.has(settled.caller) || bus.peers.get(settled.caller)!.state === "offline") return; // nobody to read it; the row is the record
1811
+ try { bus.publish(newEnvelope(HUB, rendered.body, { to: [settled.caller], kind: "invocation_result", priority: "status", refs: { invocation: settled.id, ...(invocations.relay(settled.id) ? { invocationRemote: true as const } : {}), ...(settled.task === null ? {} : { task: String(settled.task) }) }, ...(turns.get(settled.caller)?.private || contextPii(settled.caller) ? { private: true } : {}) })); }
1812
+ catch { log(`invocation ${settled.id.slice(0, 8)}: the result could not be published to ${settled.caller}; the row keeps the outcome`); }
1813
+ };
1814
+ /** A request that was never handed over (withdrawn from the queue, or its delivery was refused) must leave no effect behind. */
1815
+ const withdrawInvocation = (row: InvocationRow, outcome: InvocationOutcome, reason: string): void => {
1816
+ bus.withdraw(row.id);
1817
+ finishInvocation(row, outcome, { reason });
1818
+ };
1819
+ /**
1820
+ * Whether this envelope is part of an invocation, and the settle step when it is the callee's answer: an envelope addressed to the hub whose parent is a
1821
+ * request still open for that sender (the trace is the request's, hop + 1). Anything else returns false and flows on as before.
1822
+ */
1823
+ const handleInvocationEnvelope = (env: Envelope): boolean => {
1824
+ if (env.kind === "invocation" || env.kind === "invocation_result") {
1825
+ event({ type: "envelope", id: env.id, from: env.from, ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, kind: env.kind, ...(env.refs?.task ? { task: env.refs.task } : {}) });
1826
+ return true;
1827
+ }
1828
+ return false;
1829
+ };
1830
+ /**
1831
+ * #445 C2: a callee's answer, taken over BEFORE the bus builds anything from it (`Bus.onAnswer`). It is found by what the peer was handed, never by how the
1832
+ * answer is addressed, so an agent whose final answer is normally shared with every peer cannot leak it. Nothing here publishes, logs, cites or stores the text:
1833
+ * it goes to the screens below and then, if it passes, into the one result for the caller. Returns true whenever the answer belongs to an invocation, even a
1834
+ * settled one (a late answer after a timeout is dropped, not broadcast).
1835
+ */
1836
+ bus.onAnswer = (peer, text, handed) => {
1837
+ const request = handed.find(e => e.kind === "invocation" && e.from === HUB && e.refs?.invocation === e.id);
1838
+ if (!request) return false;
1839
+ const row = invocations.get(request.id);
1840
+ if (!row || row.target !== peer) return true; // not ours to settle, and never a broadcast
1841
+ if (row.outcome !== "queued" && row.outcome !== "delivered") return true;
1842
+ void settleAnswer(row, text);
1843
+ return true;
1844
+ };
1845
+ const settleAnswer = async (row: InvocationRow, text: string): Promise<void> => {
1846
+ let refused: string | undefined;
1847
+ const withhold = () => finishInvocation(row, refused === undefined ? "withheld" : "refused", refused === undefined ? {} : { reason: `${row.target} declined; the reason was withheld` });
1848
+ try {
1849
+ refused = refusedBy(text); // Classify only; no callee-authored reason is forwarded until the complete answer clears every screen.
1850
+ // The callee's turn may have turned private while it worked, and its words may carry PII: withhold all its text while preserving an explicit refusal outcome.
1851
+ const privateNow = () => !!turns.get(row.target)?.private || contextPii(row.target) || (row.task !== null && (() => { const t = board.get(row.task!); return !!t && tasks.isPii(t); })());
1852
+ let clear = !privateNow() && boundedSafe(text); // boundedSafe is the PII-pattern, secret and private-path screen the bounded tools use
1853
+ if (clear && tasks.screenOn()) {
1854
+ const verdict = await (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text.slice(0, 20_000)).catch(() => undefined);
1855
+ clear = verdict?.label === "clear" && !privateNow() && boundedSafe(text); // unknown is PII; recheck current privacy after the await
1856
+ }
1857
+ if (!clear) return void withhold();
1858
+ if (refused !== undefined) return void finishInvocation(row, "refused", { reason: `${row.target} declined: ${refused}` });
1859
+ finishInvocation(row, "completed", { body: text });
1860
+ } catch { withhold(); } // A failure to decide never releases callee text, including a refusal reason.
1861
+ };
1862
+ /** A target that is paused by the person, the conductor or the budget, or whose quota window is exhausted, is refused at call time (D5, quota). A pause that starts later leaves a queued request queued under its deadline. */
1863
+ const invocationPaused = (target: PeerId): boolean => bus.isPaused(target) || manualPaused.has(target) || conductorHolds.has(target) || !!budget.record(definitionOf(target)?.budget?.account ?? target);
1864
+ const invocationTimeoutMs = () => (typeof config.invocation?.timeout_s === "number" && config.invocation.timeout_s >= 10 ? config.invocation.timeout_s : DEFAULT_TIMEOUT_S) * 1000;
1865
+ /** The deadline pass: a request still queued is withdrawn (`expired`, no effect); one delivered with no result is a `timeout` (reads only in C2). */
1866
+ const sweepInvocations = (): void => {
1867
+ for (const row of invocations.pending()) if ((row.class !== "read" || !!invocations.relay(row.id)) && bus.invocationUncertain(row.id)) {
1868
+ bus.holdInvocation(row.id, "restored invocation delivery outcome is uncertain", row.target);
1869
+ finishInvocation(row, "needs_review", { reason: "delivery outcome is uncertain; check it by hand" });
1870
+ }
1871
+ for (const row of invocations.due()) {
1872
+ // A row is `queued` until the adapter reports it accepted, which an ACP agent does only at its first activity: a request already handed to the target (it left the queue, and a
1873
+ // delivery that failed safely puts it back) may be running, so it times out like a delivered one. Only a request still waiting, or not yet handed over, expires with no effect.
1874
+ const handedOver = row.outcome === "queued" && !bus.queueIds(row.target).includes(row.id);
1875
+ if (row.outcome === "queued" && !handedOver) withdrawInvocation(row, "expired", "the target did not receive it before the deadline; nothing ran");
1876
+ else {
1877
+ if (row.class !== "read" || invocations.relay(row.id)) bus.holdInvocation(row.id, "invocation result deadline passed; outcome is uncertain", row.target);
1878
+ finishInvocation(row, row.class === "read" && !invocations.relay(row.id) ? "timeout" : "needs_review", { reason: row.class === "read" && !invocations.relay(row.id) ? "the target received it but gave no result before the deadline; it may still run" : "the target received it but gave no result before the deadline; check it by hand" });
1879
+ }
1880
+ }
1881
+ };
1748
1882
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
1749
1883
  const task = board.get(receipt.task);
1750
1884
  return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
@@ -2481,6 +2615,7 @@ export async function startDaemon(opts: DaemonOptions) {
2481
2615
  };
2482
2616
  bus.onDelivered = (peer, originals) => {
2483
2617
  if (peer.includes("@")) return;
2618
+ for (const env of originals) if (env.kind === "invocation" && env.from === HUB && env.refs?.invocation === env.id) invocations.markDelivered(env.id); // #445 C2: the target accepted the request
2484
2619
  pendingDeliveryTask.delete(peer);
2485
2620
  if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
2486
2621
  const turn = turns.get(peer);
@@ -2511,9 +2646,9 @@ export async function startDaemon(opts: DaemonOptions) {
2511
2646
  if (row && tasks.isPii(row)) return;
2512
2647
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
2513
2648
  }) : undefined;
2514
- bus.onMemoryDelivery = (peer, id, out, field) => { if (!peer.includes("@") && !out.some(env => env.from.includes("@") || env.to?.some(id => id.includes("@")) || env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
2649
+ bus.onMemoryDelivery = (peer, id, out, field) => { if (!peer.includes("@") && !out.some(env => remoteEnvelope(env) || env.to?.some(id => id.includes("@")) || env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
2515
2650
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
2516
- if (peer.includes("@") || envs.some(env => env.from.includes("@") || env.to?.some(id => id.includes("@")))) return;
2651
+ if (peer.includes("@") || envs.some(env => remoteEnvelope(env) || env.to?.some(id => id.includes("@")))) return;
2517
2652
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
2518
2653
  memoryObservations?.cite(peer, text, id, envs);
2519
2654
  };
@@ -2859,6 +2994,7 @@ export async function startDaemon(opts: DaemonOptions) {
2859
2994
  } catch { log("agent CLI drift sweep failed; run ahub agents"); } finally { agentDriftRunning = false; }
2860
2995
  };
2861
2996
  const intervals = [
2997
+ setInterval(() => { try { sweepInvocations(); } catch { log("invocation deadline sweep failed; the rows keep their state"); } }, opts.invocationSweepMs ?? 5_000),
2862
2998
  setInterval(() => { void sweepAgentDrift(); }, opts.agentDriftEveryMs ?? 86_400_000),
2863
2999
  setInterval(() => {
2864
3000
  void refreshRunner().catch(() => {});
@@ -3086,6 +3222,138 @@ export async function startDaemon(opts: DaemonOptions) {
3086
3222
  && policyKey === JSON.stringify({ signals: activeRouting().signals, constraints: activeRouting().constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: activeRouting().local.fixed_model });
3087
3223
  }
3088
3224
  const sourceRegistry = new SourceRegistry(opts.stateDir);
3225
+ const answeringInvocation = (peer: PeerId): boolean => {
3226
+ if (invocations.answering(peer)) return true;
3227
+ if (!bus.hasInvocationTurn(peer)) return false;
3228
+ const request = bus.lastDeliveredTo(peer).find(env => env.kind === "invocation" && env.refs?.invocation === env.id);
3229
+ const row = request && invocations.get(request.id);
3230
+ return !!row && ["queued", "delivered", "timeout", "needs_review"].includes(row.outcome);
3231
+ };
3232
+ async function invokeCapability(by: PeerId, a: Record<string, any>, piiTurn: boolean, requireInvoke: () => void, room?: RoomInvocationIngress & { request: RoomReadInvocationRequest }): Promise<string> {
3233
+ const invocationRequest = invocationRequestSignal.getStore(), invocationSignal = invocationRequest?.signal;
3234
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["peer", "capability", "args", "task"].includes(key))) throw new PreEffectToolRefusal("hub_invoke takes peer, capability, optional args and optional task");
3235
+ requireInvoke();
3236
+ const remoteTarget = parseRemotePeerId(a.peer);
3237
+ if (remoteTarget && room) throw new PreEffectToolRefusal("remote invocations cannot relay another invocation");
3238
+ const target = remoteTarget ? a.peer as string : peerArg(a.peer, "peer");
3239
+ if (!target) throw new PreEffectToolRefusal("peer is required");
3240
+ if (typeof a.capability !== "string" || !/^(command|skill|mcp_tool):[A-Za-z0-9_.:-]{1,64}$/.test(a.capability)) throw new PreEffectToolRefusal("capability must be an id from hub_capabilities, like command:skill:review");
3241
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) < 1)) throw new PreEffectToolRefusal("task must be a task id");
3242
+ const callerPeer = bus.peers.get(by), callerGeneration = connectionGeneration(by);
3243
+ const targetPeer = bus.peers.get(target), targetGeneration = connectionGeneration(target);
3244
+ const remoteIdentity = remoteTarget ? roomChat.invocationIdentity(target, by) : undefined;
3245
+ if (!room && (!callerPeer || !callerGeneration)) throw new PreEffectToolRefusal("only an attached peer invokes capabilities");
3246
+ const boundTask = a.task === undefined ? undefined : board.get(a.task as number);
3247
+ const taskBinding = (task: typeof boundTask) => task ? JSON.stringify([task.owner, task.reviewer, task.state, tasks.isPii(task)]) : "";
3248
+ const initialTask = taskBinding(boundTask);
3249
+ const initialPolicy = JSON.stringify([config.pii, config.local.deny, tasks.screenOn()]);
3250
+ const readFacts = (view: { id: string; state: "listed" | "unshared" | "digest-changed"; class: import("./capabilities.ts").SideEffect } | undefined) => {
3251
+ const peer = bus.peers.get(target), task = a.task === undefined ? undefined : board.get(a.task as number);
3252
+ return {
3253
+ caller: by, target, capabilityId: a.capability as string, unattended: !!opts.unattended,
3254
+ piiContext: piiTurn || !!turns.get(by)?.private || contextPii(by),
3255
+ attached: !!peer && peer.state !== "offline", onRoster: !!peer && membershipAllowed(target), paused: invocationPaused(target), held: bus.isHeld(target), pullOnly: peer?.pullOnly === true,
3256
+ capability: view ? { id: view.id, state: view.state, class: view.class } : undefined,
3257
+ depth: answeringInvocation(by), openByCaller: invocations.countOpenBy("caller", by), openByTarget: invocations.countOpenBy("target", target),
3258
+ ...(a.task === undefined ? {} : { task: { id: a.task as number, ok: !!task && (task.owner === by || task.reviewer === by) && !isClosedTaskState(task.state) && task.state !== "backlog", pii: !!task && tasks.isPii(task), reason: `task #${a.task} is not open work of yours` } }),
3259
+ };
3260
+ };
3261
+ const current = () => {
3262
+ requireInvoke();
3263
+ if (invocationSignal?.aborted || invocationRequest && !invocationRequest.current()) throw new PreEffectToolRefusal("invocation request was abandoned; nothing ran");
3264
+ if (room && !room.current()) throw new PreEffectToolRefusal("remote invocation authority changed");
3265
+ if (remoteTarget && (!remoteIdentity || !remoteIdentity.current())) throw new PreEffectToolRefusal("remote invocation route changed");
3266
+ if (stopping) throw new PreEffectToolRefusal("the hub is stopping; ask again afterwards");
3267
+ if (JSON.stringify([config.pii, config.local.deny, tasks.screenOn()]) !== initialPolicy) throw new PreEffectToolRefusal("invocation privacy policy changed while the call was checked; ask again");
3268
+ if ((!room && (bus.peers.get(by) !== callerPeer || connectionGeneration(by) !== callerGeneration)) || bus.peers.get(target) !== targetPeer || connectionGeneration(target) !== targetGeneration || taskBinding(a.task === undefined ? undefined : board.get(a.task as number)) !== initialTask) throw new PreEffectToolRefusal("your session, the target session or task scope changed while the call was checked; ask again");
3269
+ };
3270
+ const lookup = async () => {
3271
+ if (!remoteTarget) return (await manifestOf(target))?.capabilities.find(c => c.id === a.capability);
3272
+ const pin = readShare(config.invocation?.share)[target]?.[a.capability as string];
3273
+ return pin?.class === "read" ? { id: a.capability as string, digest: pin.digest, name: (a.capability as string).slice((a.capability as string).indexOf(":") + 1),
3274
+ kind: (a.capability as string).split(":")[0] as "command" | "skill" | "mcp_tool", invoke: "prompt" as const, source: "manual" as const, inputSchema: undefined } : undefined;
3275
+ };
3276
+ const found = await lookup();
3277
+ current();
3278
+ const shared = (capability: Awaited<ReturnType<typeof lookup>>) => {
3279
+ if (!capability) return undefined;
3280
+ if (capability.source !== "manual") return shareView(target, { capabilities: [capability], observedAt: 0 }, readShare(config.invocation?.share))[0];
3281
+ const pin = readShare(config.invocation?.share)[target]?.[capability.id];
3282
+ return { id: capability.id, state: !pin ? "unshared" as const : pin.digest === capability.digest ? "listed" as const : "digest-changed" as const, class: pin?.class ?? "destructive" as const };
3283
+ };
3284
+ const view = shared(found);
3285
+ const refusal = invocationRefusal(readFacts(view));
3286
+ if (refusal) throw new PreEffectToolRefusal(refusal);
3287
+ const pinnedDigest = found!.digest, pinnedClass = view!.class;
3288
+ if (room && (pinnedClass !== "read" || pinnedDigest !== room.request.digest)) throw new PreEffectToolRefusal("target read capability pin does not match");
3289
+ let checked: ReturnType<typeof checkArgs>;
3290
+ try { checked = remoteTarget ? { ok: true, args: roomInvocationArgs(a.args ?? {}) } : checkArgs(found!.inputSchema, a.args, found!.invoke === "native"); }
3291
+ catch { throw new PreEffectToolRefusal("remote invocation arguments must be bounded flat scalar values"); }
3292
+ if (!checked.ok) throw new PreEffectToolRefusal(checked.reason);
3293
+ const argText = Object.values(checked.args).filter((v): v is string => typeof v === "string").join("\n");
3294
+ const revalidate = async () => {
3295
+ current();
3296
+ const capability = await lookup();
3297
+ current();
3298
+ const live = shared(capability), rejected = invocationRefusal(readFacts(live));
3299
+ if (rejected) throw new PreEffectToolRefusal(rejected);
3300
+ if (!capability || capability.digest !== pinnedDigest || live?.class !== pinnedClass) throw new PreEffectToolRefusal("the capability digest or class changed while the call was checked; ask again");
3301
+ if (argText && !boundedSafe(argText)) throw new PreEffectToolRefusal("the arguments match a PII or secret pattern, so they are not sent to another peer");
3302
+ };
3303
+ if (argText) {
3304
+ if (!boundedSafe(argText)) throw new PreEffectToolRefusal("the arguments match a PII or secret pattern, so they are not sent to another peer");
3305
+ if (tasks.screenOn()) {
3306
+ const verdict = await (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(argText).catch(() => undefined);
3307
+ if (verdict?.label !== "clear") throw new PreEffectToolRefusal("the PII screen did not clear the arguments, so they are not sent to another peer");
3308
+ }
3309
+ }
3310
+ await revalidate();
3311
+ current();
3312
+ const finalRefusal = () => invocationRefusal(readFacts(shared(found)));
3313
+ if (finalRefusal()) throw new PreEffectToolRefusal(finalRefusal()!);
3314
+ let approval: string | undefined;
3315
+ if (pinnedClass !== "read") {
3316
+ const picked = await requestPermission({ peer: by, personOnly: true, tool: "hub_invoke", title: invocationApproval({ caller: by, target, capability: found!.id, digest: pinnedDigest, class: pinnedClass, task: a.task as number, args: checked.args }), options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, invocationSignal);
3317
+ await revalidate();
3318
+ current();
3319
+ if (finalRefusal()) throw new PreEffectToolRefusal(finalRefusal()!);
3320
+ if (shared(found)?.class !== pinnedClass) throw new PreEffectToolRefusal("the capability class changed during approval; ask again");
3321
+ if (picked.outcome !== "answered" || picked.optionId !== "allow" || !picked.approvalId) throw new PreEffectToolRefusal(`invocation approval ${picked.outcome === "expired" ? "expired" : "was denied or cancelled"}; nothing ran`);
3322
+ approval = picked.approvalId;
3323
+ }
3324
+ // The request is sent by the hub, which no limit applies to, so the CALLER is charged: the same per-sender and per-pair limits as a message from it to the target (priority status, never important).
3325
+ const limited = room ? undefined : admit(newEnvelope(by, `invoke ${found!.id} ${JSON.stringify(checked.args)}`, { to: [target], kind: "invocation", priority: "status" }));
3326
+ if (limited) throw new PreEffectToolRefusal(limited);
3327
+ if (room) {
3328
+ const prior = invocations.relayByRemote(room.room, room.member, room.request.invocation, "inbound");
3329
+ if (prior) {
3330
+ if (prior.hash !== room.hash || prior.epoch !== room.epoch || prior.remotePeer !== room.remotePeer || prior.target !== target || prior.capability !== found!.id || prior.digest !== pinnedDigest) throw new PreEffectToolRefusal("remote invocation identity conflicts with its retained binding");
3331
+ if (!room.claim()) throw new Error("room: invocation claim unavailable");
3332
+ return JSON.stringify({ invocation: prior.local, state: "queued" });
3333
+ }
3334
+ if (!room.claim()) throw new Error("room: invocation claim unavailable");
3335
+ }
3336
+ const request = newEnvelope(HUB, "", { to: [target], kind: "invocation", priority: "status" });
3337
+ if (room) { request.trace = room.envelope.trace; request.hop = room.envelope.hop; }
3338
+ request.body = remoteTarget ? encodeRoomInvocationBody("invocation", { invocation: request.id, capability: found!.id, digest: pinnedDigest, class: "read", depth: 1, args: checked.args })
3339
+ : requestText({ caller: by, capabilityId: found!.id, name: found!.name, kind: found!.kind, args: checked.args, id: request.id });
3340
+ request.refs = { invocation: request.id, ...(room || remoteTarget ? { invocationRemote: true as const } : {}), ...(a.task === undefined ? {} : { task: String(a.task) }) };
3341
+ const input = { id: request.id, trace: request.trace, caller: by, target, capability: found!.id, digest: found!.digest, class: pinnedClass, ...(a.task === undefined ? {} : { task: a.task as number }), args: checked.args, timeoutMs: invocationTimeoutMs(), ...(approval ? { approval } : {}) };
3342
+ const row = room ? invocations.openRelay(input, { request: room.request.invocation, direction: "inbound", room: room.room, member: room.member, epoch: room.epoch,
3343
+ remotePeer: room.remotePeer, caller: room.envelope.from, target, capability: found!.id, digest: pinnedDigest, hash: room.hash, hop: room.envelope.hop, state: "admitted" })
3344
+ : remoteTarget && remoteIdentity ? invocations.openRelay(input, { request: request.id, direction: "outbound", room: remoteIdentity.room, member: remoteIdentity.member, epoch: remoteIdentity.epoch,
3345
+ remotePeer: target, caller: by, target: remoteTarget.peer, capability: found!.id, digest: pinnedDigest,
3346
+ hash: roomInvocationHash({ id: request.id, trace: request.trace, hop: request.hop, from: by, to: remoteTarget.peer, kind: request.kind, priority: request.priority, body: request.body, ts: request.ts }), hop: request.hop, state: "prepared" })
3347
+ : invocations.open(input);
3348
+ if (row.task !== null) {
3349
+ try { tasks.recordInvocation(row.task, row); }
3350
+ catch { invocations.settle(row.id, "refused"); throw new PreEffectToolRefusal("invocation audit unavailable; nothing ran"); }
3351
+ }
3352
+ event({ type: "invocation", id: row.id, caller: by, target, capability: row.capability, class: row.class, outcome: "queued", ...(row.task === null ? {} : { task: row.task }) });
3353
+ try { bus.publish(request); }
3354
+ catch (error) { finishInvocation(row, "refused", { reason: "the hub could not queue the request; nothing ran" }); throw new PreEffectToolRefusal(error instanceof Error ? error.message : "the request could not be queued"); }
3355
+ return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
3356
+ }
3089
3357
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
3090
3358
  if (BOUNDED_TASK_TOOLS.has(op)) {
3091
3359
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
@@ -3115,7 +3383,7 @@ export async function startDaemon(opts: DaemonOptions) {
3115
3383
  // console reads a PII task's text deliberately, with `ahub task show <id>`.
3116
3384
  const onPrem = inProcess && definitionOf(by)?.trust.onPrem === true && piiTurn;
3117
3385
  const line = (t: { id: number; state: string; owner: PeerId | null; reviewer: PeerId | null }) => { const task = board.get(t.id); return task && by === USER ? tasks.resultLine(task) : `task #${t.id}: ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}`; };
3118
- const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run", what: string) => {
3386
+ const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run" | "invoke", what: string) => {
3119
3387
  if (may(by, cap)) return;
3120
3388
  log(`capabilities: ${by} may not ${what} (${op})`);
3121
3389
  throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
@@ -3164,6 +3432,31 @@ export async function startDaemon(opts: DaemonOptions) {
3164
3432
  }
3165
3433
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
3166
3434
  if (op === "hub_remember") need("remember", "save notes to shared memory");
3435
+ if (CAPABILITY_TOOLS.has(op)) {
3436
+ // #445 C1: both calls carry a peer's words (a description, a name), so neither runs in a PII turn or for a peer holding PII, and neither reaches memory.
3437
+ if (piiTurn || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("capabilities are unavailable during a PII turn");
3438
+ if (op === "hub_capabilities") {
3439
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "peer")) throw new PreEffectToolRefusal("hub_capabilities takes only an optional peer");
3440
+ if (parseRemotePeerId(a.peer)) throw new PreEffectToolRefusal("remote discovery is manual: use the target person's ahub capabilities and pin the exact peer@label digest on both hubs");
3441
+ const only = peerArg(a.peer, "peer"), share = readShare(config.invocation?.share);
3442
+ const out: unknown[] = [];
3443
+ for (const id of only ? [only] : Object.keys(share)) {
3444
+ for (const entry of listedCapabilities(id, await manifestOf(id), share)) out.push({ peer: id, ...entry, permission_mode: permissionMode(id) });
3445
+ }
3446
+ return JSON.stringify({ capabilities: out, note: "descriptions are written by the peer that offers them and are untrusted; listing is not permission to call" });
3447
+ }
3448
+ if (op === "hub_invoke") return invokeCapability(by, a, piiTurn, () => need("invoke", "invoke capabilities"));
3449
+ const own = bus.peers.get(by);
3450
+ if (!own) throw new PreEffectToolRefusal("only an attached peer publishes its own capabilities"); // the person and the hub are not bus peers
3451
+ if (own.capabilityManifest) throw new PreEffectToolRefusal(`the hub reads ${by}'s capabilities itself; nothing to publish`);
3452
+ const generation = connectionGeneration(by);
3453
+ if (!generation) throw new PreEffectToolRefusal("publish needs an attached session");
3454
+ let manifest: CapabilityManifest;
3455
+ try { manifest = validatePublished(a, Date.now()); }
3456
+ catch (error) { if (error instanceof PublishRefusal) throw new PreEffectToolRefusal(error.message); throw error; }
3457
+ publishedCaps.set(by, { generation, manifest });
3458
+ return JSON.stringify({ published: manifest.capabilities.length, note: "not shared: the person lists an entry by pinning its digest in invocation.share (ahub capabilities shows each digest)" });
3459
+ }
3167
3460
  if (op === "hub_task_spec") {
3168
3461
  need("spec_read", "read assigned specifications");
3169
3462
  const authorized = () => {
@@ -3446,6 +3739,15 @@ export async function startDaemon(opts: DaemonOptions) {
3446
3739
  // conductor included: none of these is in CONDUCTOR_TASK_OPS). Accept writes ONE key, derived from the card's key line, through the settings writer.
3447
3740
  case "learn_list":
3448
3741
  return JSON.stringify(tasks.learnCandidates(by, { all: a.all === true }));
3742
+ // #445 C1: the person's view of every peer's capabilities, shared or not. Person only (not in CONDUCTOR_TASK_OPS); it shows what a peer offers, never calls it.
3743
+ case "capabilities_list": {
3744
+ const share = readShare(config.invocation?.share), peers: unknown[] = [];
3745
+ for (const id of [...bus.peers.keys()].sort()) {
3746
+ const manifest = await manifestOf(id);
3747
+ if (manifest?.capabilities.length) peers.push({ peer: id, observedAt: manifest.observedAt, permission_mode: permissionMode(id), entries: shareView(id, manifest, share) });
3748
+ }
3749
+ return JSON.stringify({ peers });
3750
+ }
3449
3751
  case "learn_reject":
3450
3752
  return JSON.stringify(tasks.publicView(await tasks.learnClose(by, a.id, "rejected")));
3451
3753
  case "learn_accept": {
@@ -3616,7 +3918,20 @@ export async function startDaemon(opts: DaemonOptions) {
3616
3918
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
3617
3919
  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 }>();
3618
3920
  let stopping = false;
3619
- const roomChat = new RoomChat({ bus, cwd: opts.cwd, routing: activeRouting, piiPaths: () => config.pii?.paths ?? [],
3921
+ const roomInvocations: RoomInvocations = new RoomInvocations({ bus, ledger: invocations, shares: () => readShare(config.invocation?.share), identity: (peer, sender) => roomChat.invocationIdentity(peer, sender),
3922
+ 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
+ safeResult: async (body, current) => {
3924
+ const policy = JSON.stringify([config.pii, config.local.deny, tasks.screenOn()]);
3925
+ if (!current() || !boundedSafe(body)) return false;
3926
+ if (body && tasks.screenOn()) {
3927
+ const verdict = await (opts.piiScreen ?? ((text: string) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(body).catch(() => undefined);
3928
+ if (verdict?.label !== "clear") return false;
3929
+ }
3930
+ return current() && boundedSafe(body) && policy === JSON.stringify([config.pii, config.local.deny, tasks.screenOn()]);
3931
+ }, quarantine: row => finishInvocation(row, "needs_review", { reason: "remote invocation handoff is unconfirmed after restart", quarantine: true }), finish: finishInvocation });
3932
+ bus.onInvocationReceipt = (id, state) => roomInvocations.receipt(id, state);
3933
+ roomInvocations.reconcile();
3934
+ const roomChat: RoomChat = new RoomChat({ bus, cwd: opts.cwd, routing: activeRouting, piiPaths: () => config.pii?.paths ?? [],
3620
3935
  senderPrivate: peer => peer === USER ? false : bus.peers.has(peer) && !peer.includes("@") ? !!turns.get(peer)?.private || contextPii(peer) : undefined,
3621
3936
  taskPrivate: id => { const task = board.get(id); return task ? tasks.isPii(task) : undefined; },
3622
3937
  modelScreenOn: () => tasks.screenOn(),
@@ -3624,6 +3939,7 @@ export async function startDaemon(opts: DaemonOptions) {
3624
3939
  policyVersion: () => configRevision,
3625
3940
  limits: () => config.limits,
3626
3941
  notice: value => notify(value.code === "application_capacity" ? `room ${value.room}: application capacity reached (${value.count}); resolve incoming uncertainty with ahub room resolve` : `room ${value.room}: signed frame sequence gap ${value.count} from ${value.sender}`),
3942
+ invocationAuthorization: (env, identity) => roomInvocations.authorization(env, identity), invocation: input => roomInvocations.receive(input),
3627
3943
  roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
3628
3944
  changed: () => { if (ready && !stopping) writeStatus(); },
3629
3945
  });
@@ -4312,6 +4628,14 @@ export async function startDaemon(opts: DaemonOptions) {
4312
4628
  const detecting = new Set<Promise<void>>();
4313
4629
  let attachments = 0;
4314
4630
  bus.tap((e) => {
4631
+ // #445 C2: the bus gave up on a request (poison retries exhausted): nothing ran, the caller is told once.
4632
+ if (e.t === "undeliverable" && e.env.kind === "invocation" && e.env.from === HUB) {
4633
+ const row = invocations.get(e.env.id);
4634
+ if (row) finishInvocation(row, "failed", { reason: invocations.relay(row.id) ? "the target read request was refused safely before execution" : "the request could not be delivered to the target after retries; nothing ran" });
4635
+ return;
4636
+ }
4637
+ // #445 C2: an invocation's request and result, and a callee's answer to one, are handled here and end here: no log line carries a body, no citation reaches memory.
4638
+ if (e.t === "envelope" && handleInvocationEnvelope(e.env)) return;
4315
4639
  if (e.t === "envelope" && e.env.from !== HUB && e.env.from !== USER && bus.peers.has(e.env.from) && !e.env.private) citeMemory(e.env.from, e.env.body, `message:${e.env.id}`, [e.env]);
4316
4640
  e = redact(e);
4317
4641
  uiEvents.push({ seq: ++uiSequence, event: e });
@@ -4325,7 +4649,7 @@ export async function startDaemon(opts: DaemonOptions) {
4325
4649
  // #248: the add-ons it works with, once per attach (names and versions only).
4326
4650
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
4327
4651
  // still did its work under that attachment.
4328
- if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
4652
+ if (e.state === "offline") { attachedNow.delete(e.peer); publishedCaps.delete(e.peer); applyPendingPermissionDefault(e.peer); }
4329
4653
  else if (!attachedNow.has(e.peer)) {
4330
4654
  attachedNow.add(e.peer);
4331
4655
  const peer = e.peer, attachment = ++attachments;
@@ -4530,7 +4854,7 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4854
 
4531
4855
  const permissionAnswerEpochs = new Map<string, number>();
4532
4856
  const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
4533
- type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
4857
+ type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string; approvalId?: string };
4534
4858
  async function onPermission(req: PermissionRequest, signal?: AbortSignal): Promise<string | undefined> {
4535
4859
  const external = isExternalInstance(req.peer) ? currentExecutionOwner(req.peer, true) : undefined;
4536
4860
  if (isExternalInstance(req.peer) && !external) return undefined;
@@ -4560,15 +4884,16 @@ export async function startDaemon(opts: DaemonOptions) {
4560
4884
  }
4561
4885
  return !gate.denyOnly && picked.optionId === "allow";
4562
4886
  }
4563
- async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false, lifecycle?: { registered(id: string): void; expired(id: string): void }): Promise<PermissionResult> {
4887
+ async function requestPermission(req: PermissionRequest & { personOnly?: boolean }, signal?: AbortSignal, observe?: ApprovalObserver, requirePerson = false, lifecycle?: { registered(id: string): void; expired(id: string): void }): Promise<PermissionResult> {
4564
4888
  const requestEpoch = permissionAnswerEpoch(req.peer);
4565
4889
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
4566
4890
  try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
4567
4891
  catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
4568
4892
  };
4569
4893
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
4894
+ if (req.personOnly && opts.unattended) { observed("aborted"); return { outcome: "cancelled" }; }
4570
4895
  // Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
4571
- if (opts.unattended && !req.classed && !requirePerson) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
4896
+ if (opts.unattended && !req.classed && !requirePerson && !req.personOnly) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
4572
4897
  const id = randomUUID().slice(0, 8);
4573
4898
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
4574
4899
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -4608,7 +4933,7 @@ export async function startDaemon(opts: DaemonOptions) {
4608
4933
  try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}) }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
4609
4934
  try { notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`); } catch { /* no reporting failure strands the caller */ }
4610
4935
  for (const c of consoles) if (c.data.tail) { try { c.send(JSON.stringify({ t: "permission_closed", id, peer: req.peer, outcome: outcome === "expired" ? "cancelled" : outcome, ...(outcome === "expired" ? { reason: "expired" } : {}), latencyMs })); } catch { /* another surface can still observe the closure */ } }
4611
- resolve({ outcome, ...(option ? { optionId: option.optionId } : {}) });
4936
+ resolve({ outcome, ...(option ? { optionId: option.optionId, ...(surface ? { approvalId: id } : {}) } : {}) });
4612
4937
  return true;
4613
4938
  };
4614
4939
  permissions.set(id, { push, done, peer: req.peer, ...(publicTool ? { tool: publicTool } : {}), createdAt, expiresAt });
@@ -5021,6 +5346,7 @@ export async function startDaemon(opts: DaemonOptions) {
5021
5346
  providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
5022
5347
  ...(instance?.provider ? { provider: instance.provider } : {}),
5023
5348
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
5349
+ toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
5024
5350
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
5025
5351
  // #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
5026
5352
  // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
@@ -5051,6 +5377,7 @@ export async function startDaemon(opts: DaemonOptions) {
5051
5377
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
5052
5378
  actionGate: acpActionGate(peer),
5053
5379
  cmd, kind: adapter.kind,
5380
+ toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
5054
5381
  permissionMode: permissionMode(peer),
5055
5382
  ...(args.model ? { launchModel: args.model } : {}),
5056
5383
  ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
@@ -5106,6 +5433,7 @@ export async function startDaemon(opts: DaemonOptions) {
5106
5433
  const externalLaunchId = externalBinding ? randomUUID() : undefined;
5107
5434
  if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
5108
5435
  const acpOptions: AcpOptions = {
5436
+ toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
5109
5437
  onActivity: item => activityRings[peer]?.append(item),
5110
5438
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
5111
5439
  actionGate: acpActionGate(peer),
@@ -5253,6 +5581,7 @@ export async function startDaemon(opts: DaemonOptions) {
5253
5581
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
5254
5582
  if (problem) return { ok: false, error: problem };
5255
5583
  const modelRelay = modelRelays.get(peer) ?? await startModelRelay({ omni, admitRequest: () => admitPiRequest(peer), dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
5584
+ cooldownFailures: modelTiming.cooldownFailures(), cooldownMs: modelTiming.cooldownMs(), cooldownCapMs: modelTiming.cooldownCapMs(), terminalHistoryLimit: modelTiming.terminalHistoryLimit(),
5256
5585
  routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
5257
5586
  staySwitch: () => activeRouting(),
5258
5587
  onRoute: record => event({ type: "route", peer, ...record }),
@@ -6833,6 +7162,7 @@ export async function startDaemon(opts: DaemonOptions) {
6833
7162
  if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
6834
7163
  const cancelled = incompatibleCancellationTarget();
6835
7164
  if (cancelled) return cancelled;
7165
+ if (invocations.countOpen() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("invocation"))) return `target lacks invocation; open invocations: ${invocations.countOpen()}`;
6836
7166
  if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
6837
7167
  if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
6838
7168
  if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
@@ -7204,6 +7534,12 @@ export async function startDaemon(opts: DaemonOptions) {
7204
7534
  if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
7205
7535
  if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
7206
7536
  switch (msg.t) {
7537
+ case "request_cancel": {
7538
+ if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
7539
+ const request = c.invocationRequests?.get(msg.request);
7540
+ request?.abort();
7541
+ return void reply({ t: "request_cancel", ok: true, cancelled: !!request });
7542
+ }
7207
7543
  case "permission_failure_probe": {
7208
7544
  const launch = currentImplementerTools(c), entry = currentImplementerFailure(c, launch);
7209
7545
  if (!entry || ++entry.probeCount > NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) return void reply({ ok: false, error: "permission diagnostic unavailable" });
@@ -7561,6 +7897,12 @@ export async function startDaemon(opts: DaemonOptions) {
7561
7897
  const unknown = to?.filter((id) => !bus.knownPeers().includes(id) && membershipAllowed(id)) ?? [];
7562
7898
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
7563
7899
  const inReplyTo = msg.reply_to ? bus.get(String(msg.reply_to)) : undefined;
7900
+ // #445 C2: an explicit hub_send that answers an invocation request is the answer, not a message: it is settled like any other answer and published nowhere.
7901
+ if (c.peer && inReplyTo?.kind === "invocation" && inReplyTo.from === HUB) {
7902
+ bus.onAnswer?.(c.peer, body, [inReplyTo]);
7903
+ bus.completeReply(c.peer, inReplyTo.id);
7904
+ return void reply({ t: "sent", ok: true, targets: [], recorded: false });
7905
+ }
7564
7906
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
7565
7907
  let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
7566
7908
  try { bus.checkRemotePublication(env); }
@@ -8189,10 +8531,24 @@ export async function startDaemon(opts: DaemonOptions) {
8189
8531
  );
8190
8532
  return;
8191
8533
  }
8192
- (c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {})).then(
8534
+ let invocation: AbortController | undefined;
8535
+ if (msg.op === "hub_invoke") {
8536
+ if (!Number.isSafeInteger(msg.rid) || msg.rid < 1) return void reply({ t: "task", ok: false, error: "invocation requires a request id" });
8537
+ c.invocationRequests ??= new Map();
8538
+ const previous = c.invocationRequests.get(msg.rid);
8539
+ if (previous) { previous.abort(); return void reply({ t: "task", ok: false, error: "duplicate invocation request id" }); }
8540
+ if (c.closed || sock.readyState !== WebSocket.OPEN || c.invocationRequests.size >= 64) return void reply({ t: "task", ok: false, error: "invocation request connection unavailable" });
8541
+ invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
8542
+ }
8543
+ const run = async () => c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {});
8544
+ (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
8545
+ const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
8546
+ if (!live) invocation?.abort();
8547
+ return live;
8548
+ } }, run) : run()).then(
8193
8549
  (text) => reply({ t: "task", ok: true, text }),
8194
8550
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
8195
- );
8551
+ ).finally(() => { if (invocation && c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
8196
8552
  return;
8197
8553
  case "pause":
8198
8554
  case "resume": {