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