@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.
- 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 +5 -2
- package/docs/agent-notes/daemon.md +5 -4
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -1
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -2
- package/docs/dogfood.md +10 -2
- package/docs/operations.md +192 -6
- package/docs/profiles.md +29 -4
- package/docs/quickstart.md +9 -1
- package/docs/security.md +7 -1
- package/docs/smoke.md +6 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-0.23.0.md +70 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -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-recovery-wait-settings.md +141 -0
- package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
- package/docs/verified.json +15 -15
- package/package.json +1 -1
- 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 +559 -115
- 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/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 +3 -1
- package/src/cli/identity.ts +4 -1
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +28 -2
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/smoke-pty-bridge.ts +67 -18
- package/src/cli/smoke-pty.ts +10 -2
- package/src/cli/upgrade-runtime.ts +16 -14
- package/src/cli/upgrade.ts +2 -1
- package/src/hub/board.ts +8 -2
- package/src/hub/bus.ts +117 -17
- package/src/hub/capabilities.ts +215 -0
- package/src/hub/config-apply.ts +8 -4
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +41 -8
- package/src/hub/daemon.ts +436 -33
- package/src/hub/deferred-hub.ts +2 -2
- package/src/hub/delivery-hints.ts +2 -2
- package/src/hub/delivery-journal.ts +80 -61
- 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/learning.ts +33 -1
- package/src/hub/operational-settings.ts +180 -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 +22 -2
- package/src/hub/shipped-packs.ts +5 -2
- package/src/hub/shipped-roles.ts +13 -0
- package/src/hub/tasks.ts +92 -1
- 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,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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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": {
|