@staix/agent-hub 0.21.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 (100) hide show
  1. package/CHANGELOG.md +19 -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 +5 -2
  6. package/docs/agent-notes/daemon.md +5 -4
  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 +2 -1
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/dogfood.md +10 -2
  12. package/docs/operations.md +192 -6
  13. package/docs/profiles.md +29 -4
  14. package/docs/quickstart.md +9 -1
  15. package/docs/security.md +7 -1
  16. package/docs/smoke.md +6 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
  18. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  19. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  20. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  21. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  22. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  23. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  24. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  25. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  26. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  27. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  28. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  29. package/docs/verified.json +15 -15
  30. package/package.json +1 -1
  31. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  32. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  33. package/plugins/agent-hub/server.js +559 -115
  34. package/src/adapters/acp.ts +48 -2
  35. package/src/adapters/claude-channel.ts +6 -4
  36. package/src/adapters/codex-appserver.ts +19 -0
  37. package/src/adapters/jcode.ts +1 -1
  38. package/src/adapters/local-worker.ts +3 -3
  39. package/src/adapters/native-capabilities.ts +50 -0
  40. package/src/adapters/registry-types.ts +3 -10
  41. package/src/cli/capabilities-output.ts +30 -0
  42. package/src/cli/console-state.ts +8 -5
  43. package/src/cli/console.ts +27 -4
  44. package/src/cli/help.ts +3 -1
  45. package/src/cli/identity.ts +4 -1
  46. package/src/cli/learn-output.ts +38 -0
  47. package/src/cli/main.ts +28 -2
  48. package/src/cli/recovery-waits.ts +26 -0
  49. package/src/cli/smoke-pty-bridge.ts +67 -18
  50. package/src/cli/smoke-pty.ts +10 -2
  51. package/src/cli/upgrade-runtime.ts +16 -14
  52. package/src/cli/upgrade.ts +2 -1
  53. package/src/hub/board.ts +8 -2
  54. package/src/hub/bus.ts +117 -17
  55. package/src/hub/capabilities.ts +215 -0
  56. package/src/hub/config-apply.ts +8 -4
  57. package/src/hub/config-trust.ts +1 -0
  58. package/src/hub/control-client.ts +41 -8
  59. package/src/hub/daemon.ts +436 -33
  60. package/src/hub/deferred-hub.ts +2 -2
  61. package/src/hub/delivery-hints.ts +2 -2
  62. package/src/hub/delivery-journal.ts +80 -61
  63. package/src/hub/envelope.ts +12 -2
  64. package/src/hub/events.ts +2 -0
  65. package/src/hub/hub-tools.ts +16 -1
  66. package/src/hub/inference.ts +10 -5
  67. package/src/hub/invocations.ts +316 -0
  68. package/src/hub/learning.ts +33 -1
  69. package/src/hub/operational-settings.ts +180 -1
  70. package/src/hub/peers.ts +3 -0
  71. package/src/hub/restart.ts +2 -2
  72. package/src/hub/room-invocations.ts +145 -0
  73. package/src/hub/settings.ts +22 -2
  74. package/src/hub/shipped-packs.ts +5 -2
  75. package/src/hub/shipped-roles.ts +13 -0
  76. package/src/hub/tasks.ts +92 -1
  77. package/src/memory/capture.ts +2 -2
  78. package/src/models/relay.ts +20 -9
  79. package/src/rooms/application.ts +67 -2
  80. package/src/rooms/boundary.ts +43 -11
  81. package/src/rooms/service.ts +40 -2
  82. package/templates/checks/analysis.mjs +59 -0
  83. package/templates/checks/fixtures/analysis-expected.json +1 -0
  84. package/templates/checks/fixtures/analysis-input.json +1 -0
  85. package/templates/checks/fixtures/analysis.mjs +7 -0
  86. package/templates/checks/fixtures/ops.json +4 -0
  87. package/templates/checks/fixtures/writing-rules.json +4 -0
  88. package/templates/checks/fixtures/writing.json +5 -0
  89. package/templates/checks/ops.mjs +40 -0
  90. package/templates/checks/writing.mjs +40 -0
  91. package/templates/packs/analysis.pack.json +14 -0
  92. package/templates/packs/ops.pack.json +14 -0
  93. package/templates/packs/writing-pack.pack.json +14 -0
  94. package/templates/roles/NOTICE.md +1 -1
  95. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  96. package/templates/roles/ops/project-manager.md +26 -0
  97. package/templates/roles/writing/editor.md +26 -0
  98. package/templates/roles/writing/style-checker.md +26 -0
  99. package/templates/roles/writing/terminology-checker.md +26 -0
  100. 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,10 @@ 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";
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";
84
89
  import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
85
90
  import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
86
91
  import type { ScannedFile } from "./schedules.ts";
@@ -138,7 +143,7 @@ import { Briefs, type BriefTask } from "../memory/brief.ts";
138
143
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
139
144
  import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
140
145
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
141
- import { HUB } from "./envelope.ts";
146
+ import { remoteEnvelope, HUB } from "./envelope.ts";
142
147
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
143
148
  import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, statSync } from "node:fs";
144
149
  import { basename, isAbsolute } from "node:path";
@@ -146,7 +151,9 @@ import { fileURLToPath } from "node:url";
146
151
  import { realPath, hubHome } from "./project.ts";
147
152
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
148
153
  import type { BusEvent, PublishResult } from "./bus.ts";
149
- 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";
150
157
  import { Conductor, ConductorHolds, conductorPeer, requireConductor, conductorProgressSink, publicConductorTask, publicPeerBudget, taskShowAllowed, type ConductEvent } from "./conductor.ts";
151
158
  import { SupervisionFeed } from "./supervision.ts";
152
159
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -155,7 +162,8 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
155
162
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig, type PiiVerdict } from "./inference.ts";
156
163
  import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
157
164
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
158
- import { 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";
165
+ 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";
159
167
  import { githubOriginRepository } from "./task-repository.ts";
160
168
  import { Bus } from "./bus.ts";
161
169
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
@@ -219,6 +227,14 @@ export interface HubConfig {
219
227
  batch_max: number;
220
228
  batch_ms: number;
221
229
  queue_cap: number;
230
+ /** #489 H3: accepted delivery policy, read at the next delivery operation without rewriting queued work. */
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"];
222
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). */
223
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 };
224
240
  roster: PeerRoster;
@@ -269,9 +285,12 @@ export interface HubConfig {
269
285
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
270
286
  review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
271
287
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
272
- recovery: { auto_resume_after_crash: boolean };
288
+ /** #489 H4: the five waits the upgrade coordinator reads from this project's config at the start of each wait; the daemon itself only validates and carries them. */
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 };
273
290
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
274
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 };
275
294
  /**
276
295
  * How owners of overlapping tasks coordinate (issue #107): "advisory" lets them message each other; "turn-free" has
277
296
  * them work without messages while the hub derives what they need to know. Anything else is advisory.
@@ -297,12 +316,16 @@ export const DEFAULT_CONFIG: HubConfig = {
297
316
  batch_max: 3,
298
317
  batch_ms: 15_000,
299
318
  queue_cap: 200,
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 },
300
321
  memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
301
322
  roster: {},
302
323
  roles: DEFAULT_ROLES,
303
324
  conductor: { feed: "own", approval_wait_s: 30 },
304
325
  budget: DEFAULT_BUDGET,
305
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 },
306
329
  inference: DEFAULT_INFERENCE,
307
330
  omniroute: DEFAULT_OMNIROUTE,
308
331
  ...nativeDefaults.rpc,
@@ -324,7 +347,7 @@ export const DEFAULT_CONFIG: HubConfig = {
324
347
  terminal: { open: [] },
325
348
  limits: DEFAULT_LIMITS,
326
349
  review: { adaptive: false, min_reviews: 5 },
327
- recovery: { auto_resume_after_crash: false },
350
+ recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
328
351
  capabilities: {},
329
352
  coordination: "advisory",
330
353
  };
@@ -332,14 +355,14 @@ export const DEFAULT_CONFIG: HubConfig = {
332
355
  export { stateDirFor };
333
356
 
334
357
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
335
- 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"]);
336
359
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
337
360
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
338
361
 
339
362
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
340
363
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
341
364
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
342
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "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"];
343
366
 
344
367
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
345
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[] } {
@@ -532,6 +555,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
532
555
  notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
533
556
  },
534
557
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
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 },
535
562
  task_sweep: taskSweepConfig(file.task_sweep),
536
563
  schedules: schedulesConfig(file.schedules),
537
564
  folder_triggers: foldersConfig(file.folder_triggers),
@@ -624,6 +651,8 @@ export interface DaemonOptions {
624
651
  agentDrift?: boolean | (() => Promise<readonly AgentVersionRow[]>);
625
652
  /** Test seam: the period of the agent drift sweep, default one day. */
626
653
  agentDriftEveryMs?: number;
654
+ /** Test seam: the period of the invocation deadline sweep (#445 C2), default 5 s. */
655
+ invocationSweepMs?: number;
627
656
  /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
628
657
  bandsHeld?: () => boolean;
629
658
  /** Test seam: the schedules clock and the period of the due check (default Date.now and one minute). */
@@ -669,6 +698,8 @@ interface Client {
669
698
  watch?: () => void;
670
699
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
671
700
  kimiAdmission?: string;
701
+ invocationRequests?: Map<number, AbortController>;
702
+ closed?: true;
672
703
  }
673
704
  type Sock = ServerWebSocket<Client>;
674
705
 
@@ -764,6 +795,7 @@ export async function startDaemon(opts: DaemonOptions) {
764
795
  const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
765
796
  ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
766
797
  const executionRoots = new Map<string, string>();
798
+ const invocationRequestSignal = new AsyncLocalStorage<{ signal: AbortSignal; current: () => boolean }>();
767
799
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
768
800
  const implementerTranscripts = new Map<string, string>();
769
801
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
@@ -911,6 +943,8 @@ export async function startDaemon(opts: DaemonOptions) {
911
943
  const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
912
944
  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;
913
945
  };
946
+ // #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);
914
948
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
915
949
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
916
950
  let rosterChanging: string | undefined;
@@ -1110,6 +1144,9 @@ export async function startDaemon(opts: DaemonOptions) {
1110
1144
  }
1111
1145
  },
1112
1146
  close(sock) {
1147
+ sock.data.closed = true;
1148
+ for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
1149
+ sock.data.invocationRequests?.clear();
1113
1150
  nativeHookLaunches.endOwner(sock);
1114
1151
  consoles.delete(sock);
1115
1152
  operationalLogSubscribers.delete(sock);
@@ -1172,18 +1209,21 @@ export async function startDaemon(opts: DaemonOptions) {
1172
1209
 
1173
1210
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1174
1211
  let inference: Inference | undefined;
1175
- const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
1212
+ const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
1213
+ const adapterTiming = adapterOperationalReaders(() => config);
1214
+ const modelTiming = modelOperationalReaders(() => config);
1215
+ const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1176
1216
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1177
1217
  const lost = new Map<PeerId, JournalDelivery[]>();
1178
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]);
1179
1219
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1180
1220
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1181
- 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 => {
1182
1222
  if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
1183
1223
  const list = config.capabilities[peer];
1184
1224
  return Array.isArray(list) && list.includes(cap);
1185
1225
  };
1186
- 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"];
1187
1227
  for (const [peer, list] of Object.entries(config.capabilities)) {
1188
1228
  if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
1189
1229
  else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
@@ -1292,7 +1332,7 @@ export async function startDaemon(opts: DaemonOptions) {
1292
1332
  const queuePolicy = () => queueBackpressurePolicy(config.tasks.queue_backpressure);
1293
1333
  let remotePending = (_room: string): readonly string[] => [];
1294
1334
  let remoteQueueChanged = () => {};
1295
- const bus: Bus = new Bus({ rostered: membershipAllowed, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1335
+ const bus: Bus = new Bus({ rostered: membershipAllowed, ...busReaders, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1296
1336
  remotePending: room => remotePending(room),
1297
1337
  remotePrivate: (env): boolean => {
1298
1338
  const task = env.refs?.task;
@@ -1300,7 +1340,8 @@ export async function startDaemon(opts: DaemonOptions) {
1300
1340
  if (typeof task !== "string" || !/^[1-9][0-9]*$/.test(task) || !Number.isSafeInteger(Number(task))) return true;
1301
1341
  const row = board.get(Number(task)); if (!row || tasks.isPii(row)) return true;
1302
1342
  }
1303
- 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);
1304
1345
  },
1305
1346
  doing: (peer, kind, text) => observeDoing(peer, kind, text),
1306
1347
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
@@ -1666,6 +1707,10 @@ export async function startDaemon(opts: DaemonOptions) {
1666
1707
  };
1667
1708
  const inboxPolling = new InboxPolling();
1668
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" }); };
1669
1714
  const codeFacts = new CodeFacts();
1670
1715
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1671
1716
  const memorySymbols = new Map<string, MemorySymbolNodes>();
@@ -1735,6 +1780,105 @@ export async function startDaemon(opts: DaemonOptions) {
1735
1780
  if (hasGeneration && native === undefined) return undefined;
1736
1781
  return `${instanceId}:${token}:${String(native ?? "")}`;
1737
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
+ };
1738
1882
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
1739
1883
  const task = board.get(receipt.task);
1740
1884
  return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
@@ -2471,6 +2615,7 @@ export async function startDaemon(opts: DaemonOptions) {
2471
2615
  };
2472
2616
  bus.onDelivered = (peer, originals) => {
2473
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
2474
2619
  pendingDeliveryTask.delete(peer);
2475
2620
  if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
2476
2621
  const turn = turns.get(peer);
@@ -2501,9 +2646,9 @@ export async function startDaemon(opts: DaemonOptions) {
2501
2646
  if (row && tasks.isPii(row)) return;
2502
2647
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
2503
2648
  }) : undefined;
2504
- 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); };
2505
2650
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
2506
- 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;
2507
2652
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
2508
2653
  memoryObservations?.cite(peer, text, id, envs);
2509
2654
  };
@@ -2849,6 +2994,7 @@ export async function startDaemon(opts: DaemonOptions) {
2849
2994
  } catch { log("agent CLI drift sweep failed; run ahub agents"); } finally { agentDriftRunning = false; }
2850
2995
  };
2851
2996
  const intervals = [
2997
+ setInterval(() => { try { sweepInvocations(); } catch { log("invocation deadline sweep failed; the rows keep their state"); } }, opts.invocationSweepMs ?? 5_000),
2852
2998
  setInterval(() => { void sweepAgentDrift(); }, opts.agentDriftEveryMs ?? 86_400_000),
2853
2999
  setInterval(() => {
2854
3000
  void refreshRunner().catch(() => {});
@@ -3076,6 +3222,138 @@ export async function startDaemon(opts: DaemonOptions) {
3076
3222
  && policyKey === JSON.stringify({ signals: activeRouting().signals, constraints: activeRouting().constraints, paths: config.pii?.paths ?? [], mlx: config.mlx, model: activeRouting().local.fixed_model });
3077
3223
  }
3078
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
+ }
3079
3357
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
3080
3358
  if (BOUNDED_TASK_TOOLS.has(op)) {
3081
3359
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
@@ -3105,7 +3383,7 @@ export async function startDaemon(opts: DaemonOptions) {
3105
3383
  // console reads a PII task's text deliberately, with `ahub task show <id>`.
3106
3384
  const onPrem = inProcess && definitionOf(by)?.trust.onPrem === true && piiTurn;
3107
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"}`; };
3108
- 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) => {
3109
3387
  if (may(by, cap)) return;
3110
3388
  log(`capabilities: ${by} may not ${what} (${op})`);
3111
3389
  throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
@@ -3154,6 +3432,31 @@ export async function startDaemon(opts: DaemonOptions) {
3154
3432
  }
3155
3433
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
3156
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
+ }
3157
3460
  if (op === "hub_task_spec") {
3158
3461
  need("spec_read", "read assigned specifications");
3159
3462
  const authorized = () => {
@@ -3432,6 +3735,42 @@ export async function startDaemon(opts: DaemonOptions) {
3432
3735
  return JSON.stringify((await tasks.approveIntake(by, a.id)).map(task => tasks.publicView(task)));
3433
3736
  case "intake_discard":
3434
3737
  return JSON.stringify((await tasks.discardIntake(by, a.id)).map(task => tasks.publicView(task)));
3738
+ // #514b: the person's verdict on a recurring-criterion candidate. Person only (Tasks refuses any other `by`, and the generic gate above already refuses every non-person caller, the configured
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.
3740
+ case "learn_list":
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
+ }
3751
+ case "learn_reject":
3752
+ return JSON.stringify(tasks.publicView(await tasks.learnClose(by, a.id, "rejected")));
3753
+ case "learn_accept": {
3754
+ // A card the person already accepted answers with a success that writes nothing: no config, no undo record, no event. A rejected or otherwise closed card falls through and is refused.
3755
+ const decided = tasks.learnAccepted(by, a.id);
3756
+ if (decided) return JSON.stringify({ card: tasks.publicView(board.get(decided.id)!), accepted: { profile: decided.profile, class: decided.class, criterion: decided.criterion, alreadyAccepted: true, file: ".agenthub/config.local.json" } });
3757
+ const plan = tasks.learnAcceptPlan(by, a.id);
3758
+ const stored = storedConfig();
3759
+ if (!stored) throw new PreEffectToolRefusal("the config files do not load; fix them in your editor first: nothing was written");
3760
+ const active = profileName(stored.profile);
3761
+ if (plan.profile !== active) throw new PreEffectToolRefusal(`this card is for the ${plan.profile} profile and the project's profile is ${active}: nothing was written; reject the card, or switch profile first`);
3762
+ // review_checklist replaces as a unit: extend the checklist the profile uses now (the person's, else the shipped one), never start from one line.
3763
+ const current = resolveProfile(stored.profile, stored.profile_overrides).review_checklist ?? [];
3764
+ const next = withChecklistLine(current, plan.line);
3765
+ if ("refused" in next) throw new PreEffectToolRefusal(next.refused);
3766
+ const already = next.list.length === current.length;
3767
+ if (!already) {
3768
+ const written = await settingsSet(LEARN_CHECKLIST.key, next.list, "terminal", undefined, undefined, `ahub learn accept #${plan.id}`, LEARN_CHECKLIST);
3769
+ if (written.ok !== true) throw new PreEffectToolRefusal(String(written.error ?? "the checklist could not be written") + "; the candidate stays open");
3770
+ }
3771
+ const closed = await tasks.learnClose(by, a.id, "accepted");
3772
+ return JSON.stringify({ card: tasks.publicView(closed), accepted: { profile: plan.profile, class: plan.class, criterion: plan.criterion, line: plan.line, tasks: plan.tasks, lines: next.list.length, alreadyPresent: already, file: ".agenthub/config.local.json", applies: "after a restart (profile contracts are bound at startup)", undo: already ? undefined : "ahub settings undo" } });
3773
+ }
3435
3774
  case "review_brief": {
3436
3775
  const task = board.get(Number(a.id));
3437
3776
  return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
@@ -3579,7 +3918,20 @@ export async function startDaemon(opts: DaemonOptions) {
3579
3918
  const nativeUnattended = (peer: string): boolean => startingUnattended.get(peer) ?? unattendedPeers.has(peer);
3580
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 }>();
3581
3920
  let stopping = false;
3582
- 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 ?? [],
3583
3935
  senderPrivate: peer => peer === USER ? false : bus.peers.has(peer) && !peer.includes("@") ? !!turns.get(peer)?.private || contextPii(peer) : undefined,
3584
3936
  taskPrivate: id => { const task = board.get(id); return task ? tasks.isPii(task) : undefined; },
3585
3937
  modelScreenOn: () => tasks.screenOn(),
@@ -3587,6 +3939,7 @@ export async function startDaemon(opts: DaemonOptions) {
3587
3939
  policyVersion: () => configRevision,
3588
3940
  limits: () => config.limits,
3589
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),
3590
3943
  roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
3591
3944
  changed: () => { if (ready && !stopping) writeStatus(); },
3592
3945
  });
@@ -4275,6 +4628,14 @@ export async function startDaemon(opts: DaemonOptions) {
4275
4628
  const detecting = new Set<Promise<void>>();
4276
4629
  let attachments = 0;
4277
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;
4278
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]);
4279
4640
  e = redact(e);
4280
4641
  uiEvents.push({ seq: ++uiSequence, event: e });
@@ -4288,7 +4649,7 @@ export async function startDaemon(opts: DaemonOptions) {
4288
4649
  // #248: the add-ons it works with, once per attach (names and versions only).
4289
4650
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
4290
4651
  // still did its work under that attachment.
4291
- 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); }
4292
4653
  else if (!attachedNow.has(e.peer)) {
4293
4654
  attachedNow.add(e.peer);
4294
4655
  const peer = e.peer, attachment = ++attachments;
@@ -4493,7 +4854,7 @@ export async function startDaemon(opts: DaemonOptions) {
4493
4854
 
4494
4855
  const permissionAnswerEpochs = new Map<string, number>();
4495
4856
  const permissionAnswerEpoch = (peer: string) => permissionAnswerEpochs.get(peer) ?? 0;
4496
- type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string };
4857
+ type PermissionResult = { outcome: "answered" | "expired" | "cancelled"; optionId?: string; approvalId?: string };
4497
4858
  async function onPermission(req: PermissionRequest, signal?: AbortSignal): Promise<string | undefined> {
4498
4859
  const external = isExternalInstance(req.peer) ? currentExecutionOwner(req.peer, true) : undefined;
4499
4860
  if (isExternalInstance(req.peer) && !external) return undefined;
@@ -4523,15 +4884,16 @@ export async function startDaemon(opts: DaemonOptions) {
4523
4884
  }
4524
4885
  return !gate.denyOnly && picked.optionId === "allow";
4525
4886
  }
4526
- 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> {
4527
4888
  const requestEpoch = permissionAnswerEpoch(req.peer);
4528
4889
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
4529
4890
  try { observe?.({ source, answerEpoch: permissionAnswerEpoch(req.peer), ...(source === "expired" ? { eligibleExpiry: requestEpoch === permissionAnswerEpoch(req.peer) } : {}) }); }
4530
4891
  catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
4531
4892
  };
4532
4893
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
4894
+ if (req.personOnly && opts.unattended) { observed("aborted"); return { outcome: "cancelled" }; }
4533
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.
4534
- 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 }; }
4535
4897
  const id = randomUUID().slice(0, 8);
4536
4898
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
4537
4899
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -4571,7 +4933,7 @@ export async function startDaemon(opts: DaemonOptions) {
4571
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 */ } }
4572
4934
  try { notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`); } catch { /* no reporting failure strands the caller */ }
4573
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 */ } }
4574
- resolve({ outcome, ...(option ? { optionId: option.optionId } : {}) });
4936
+ resolve({ outcome, ...(option ? { optionId: option.optionId, ...(surface ? { approvalId: id } : {}) } : {}) });
4575
4937
  return true;
4576
4938
  };
4577
4939
  permissions.set(id, { push, done, peer: req.peer, ...(publicTool ? { tool: publicTool } : {}), createdAt, expiresAt });
@@ -4984,6 +5346,7 @@ export async function startDaemon(opts: DaemonOptions) {
4984
5346
  providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
4985
5347
  ...(instance?.provider ? { provider: instance.provider } : {}),
4986
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(),
4987
5350
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
4988
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
4989
5352
  // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
@@ -5014,6 +5377,7 @@ export async function startDaemon(opts: DaemonOptions) {
5014
5377
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
5015
5378
  actionGate: acpActionGate(peer),
5016
5379
  cmd, kind: adapter.kind,
5380
+ toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
5017
5381
  permissionMode: permissionMode(peer),
5018
5382
  ...(args.model ? { launchModel: args.model } : {}),
5019
5383
  ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
@@ -5069,6 +5433,7 @@ export async function startDaemon(opts: DaemonOptions) {
5069
5433
  const externalLaunchId = externalBinding ? randomUUID() : undefined;
5070
5434
  if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
5071
5435
  const acpOptions: AcpOptions = {
5436
+ toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
5072
5437
  onActivity: item => activityRings[peer]?.append(item),
5073
5438
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
5074
5439
  actionGate: acpActionGate(peer),
@@ -5216,6 +5581,7 @@ export async function startDaemon(opts: DaemonOptions) {
5216
5581
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
5217
5582
  if (problem) return { ok: false, error: problem };
5218
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(),
5219
5585
  routeSessionKey: () => { const session = bus.peers.get(peer)?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
5220
5586
  staySwitch: () => activeRouting(),
5221
5587
  onRoute: record => event({ type: "route", peer, ...record }),
@@ -6475,13 +6841,17 @@ export async function startDaemon(opts: DaemonOptions) {
6475
6841
  if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
6476
6842
  return sanitize(reason.trim());
6477
6843
  };
6478
- async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
6844
+ /** `managed` (#514b) is the learn command's own definition: a function argument, never read from a request, so no other key or caller can reach the managed row. */
6845
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, managed?: SettingDef): Promise<Record<string, unknown>> {
6479
6846
  checkOperational(authority);
6480
6847
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
6481
6848
  let reason: string | undefined;
6482
6849
  try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
6483
6850
  if(authority==="conductor" && reason && !tasks.publicMetadataText(reason))return {ok:false,error:"setting reason withheld by privacy policy"};
6484
- const input = settingInput(key, raw);
6851
+ if (managed && (managed !== LEARN_CHECKLIST || authority !== "terminal")) return { ok: false, error: "a managed setting is written only by its own command, as the person" };
6852
+ let input: ReturnType<typeof settingInput>;
6853
+ if (managed) { try { input = { def: managed, value: checkSettingValue(managed, raw) }; } catch (error) { return { ok: false, error: (error as Error).message }; } }
6854
+ else input = settingInput(key, raw);
6485
6855
  if ("error" in input) return { ok: false, error: input.error };
6486
6856
  const { def, value } = input;
6487
6857
  const refusal = settingRefusal(def, value, authority);
@@ -6520,11 +6890,13 @@ export async function startDaemon(opts: DaemonOptions) {
6520
6890
  const result = await reloadConfig(authority, def.path.join("."));
6521
6891
  if (result.ok !== true) return { ...result, saved: true };
6522
6892
  const to = settingScalar(valueAt(storedConfig(), def.path));
6523
- recordSetting(def.key, from, to, source, false, reason);
6893
+ // A managed checklist is the person's own profile text: the event and the notice carry line counts, never the lines.
6894
+ const shown = (v: SettingValue): SettingValue => managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6895
+ recordSetting(def.key, shown(from), shown(to), source, false, reason);
6524
6896
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
6525
6897
  const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
6526
6898
  : ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
6527
- return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
6899
+ return { ok: true, text: `${def.key}: ${settingText(shown(to))} (${timing})` };
6528
6900
  } catch (error) {
6529
6901
  return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
6530
6902
  }
@@ -6541,8 +6913,9 @@ export async function startDaemon(opts: DaemonOptions) {
6541
6913
  if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
6542
6914
  if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
6543
6915
  }
6544
- const def = settingDef(identity?.key);
6916
+ const def = undoableDef(identity?.key);
6545
6917
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6918
+ 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` };
6546
6919
  if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
6547
6920
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
6548
6921
  const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
@@ -6553,9 +6926,11 @@ export async function startDaemon(opts: DaemonOptions) {
6553
6926
  undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
6554
6927
  const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
6555
6928
  if (result.ok !== true) return { ...result, saved: true };
6556
- recordSetting(def.key, from, read(), settingSource(authority), true, reason);
6929
+ // A managed checklist is the person's own profile text: the event, the notice and the reply carry its line count, never the lines (the same form as the write).
6930
+ const shown = (v: SettingValue): SettingValue => def.managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6931
+ recordSetting(def.key, shown(from), shown(read()), settingSource(authority), true, reason);
6557
6932
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
6558
- return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
6933
+ return { ok: true, text: `${def.key}: put back to ${settingText(shown(read()))}` };
6559
6934
  } catch (error) {
6560
6935
  return { ok: false, error: (error as Error).message };
6561
6936
  }
@@ -6594,8 +6969,9 @@ export async function startDaemon(opts: DaemonOptions) {
6594
6969
  }
6595
6970
 
6596
6971
  function settingsUndoPreview(): Record<string, unknown> {
6597
- const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
6972
+ const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
6598
6973
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6974
+ 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."] };
6599
6975
  const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
6600
6976
  if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
6601
6977
  return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
@@ -6786,6 +7162,7 @@ export async function startDaemon(opts: DaemonOptions) {
6786
7162
  if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
6787
7163
  const cancelled = incompatibleCancellationTarget();
6788
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()}`;
6789
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";
6790
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";
6791
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";
@@ -7157,6 +7534,12 @@ export async function startDaemon(opts: DaemonOptions) {
7157
7534
  if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
7158
7535
  if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
7159
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
+ }
7160
7543
  case "permission_failure_probe": {
7161
7544
  const launch = currentImplementerTools(c), entry = currentImplementerFailure(c, launch);
7162
7545
  if (!entry || ++entry.probeCount > NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH) return void reply({ ok: false, error: "permission diagnostic unavailable" });
@@ -7514,6 +7897,12 @@ export async function startDaemon(opts: DaemonOptions) {
7514
7897
  const unknown = to?.filter((id) => !bus.knownPeers().includes(id) && membershipAllowed(id)) ?? [];
7515
7898
  if (unknown.length) return void reply({ t: "sent", ok: false, error: `unknown peer: ${unknown.join(", ")}` });
7516
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
+ }
7517
7906
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
7518
7907
  let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
7519
7908
  try { bus.checkRemotePublication(env); }
@@ -8142,10 +8531,24 @@ export async function startDaemon(opts: DaemonOptions) {
8142
8531
  );
8143
8532
  return;
8144
8533
  }
8145
- (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(
8146
8549
  (text) => reply({ t: "task", ok: true, text }),
8147
8550
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
8148
- );
8551
+ ).finally(() => { if (invocation && c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
8149
8552
  return;
8150
8553
  case "pause":
8151
8554
  case "resume": {