@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/bus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
1
|
+
import { remoteEnvelope, appendNote, DIGEST, frame, HUB, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
2
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import type { DoingKind } from "./peer-doing.ts";
|
|
4
4
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
@@ -41,6 +41,11 @@ export interface BusOptions {
|
|
|
41
41
|
/** How long a status envelope may wait for company before it is delivered on its own. */
|
|
42
42
|
batchMs: number;
|
|
43
43
|
queueCap: number;
|
|
44
|
+
/** Accepted live operational settings; read only at the next matching delivery/note operation. */
|
|
45
|
+
maxAttempts?: () => number;
|
|
46
|
+
digestMax?: () => number;
|
|
47
|
+
pullBudgetBytes?: () => number;
|
|
48
|
+
notesKept?: () => number;
|
|
44
49
|
/** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
|
|
45
50
|
condense?: (envs: Envelope[]) => Promise<Envelope[]>;
|
|
46
51
|
journal?: DeliveryJournal;
|
|
@@ -118,7 +123,7 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
|
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
const SEEN_CAP = 2048;
|
|
121
|
-
const MAX_ATTEMPTS = 3;
|
|
126
|
+
export const MAX_ATTEMPTS = 3;
|
|
122
127
|
// ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
|
|
123
128
|
// Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
|
|
124
129
|
const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
|
|
@@ -127,11 +132,11 @@ const remoteId = (id: string): boolean => id.includes("@");
|
|
|
127
132
|
const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
128
133
|
// ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
|
|
129
134
|
// Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
|
|
130
|
-
const DIGEST_MAX = 10;
|
|
135
|
+
export const DIGEST_MAX = 10;
|
|
131
136
|
// #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
|
|
132
137
|
// Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
|
|
133
|
-
const PULL_BUDGET_BYTES = 64 * 1024;
|
|
134
|
-
const NOTES_KEPT = 10;
|
|
138
|
+
export const PULL_BUDGET_BYTES = 64 * 1024;
|
|
139
|
+
export const NOTES_KEPT = 10;
|
|
135
140
|
|
|
136
141
|
/**
|
|
137
142
|
* N-peer fan-out. Never delivers back to `from`, caps hops, dedupes by id. Each peer has one queue,
|
|
@@ -177,6 +182,41 @@ export class Bus {
|
|
|
177
182
|
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
178
183
|
*/
|
|
179
184
|
onQueues?: () => void;
|
|
185
|
+
/**
|
|
186
|
+
* #445 C2: a peer's answer to a delivery, offered BEFORE anything is built from it. The host returns true when the answer belongs to an invocation it
|
|
187
|
+
* took over: the bus then publishes nothing, stores nothing, emits no event and lets no tap, queue, digest or memory path see it. `answers` is what the
|
|
188
|
+
* peer was handed (the request is in it, never condensed), so a reply that answers an invocation is found by identity, not by addressing.
|
|
189
|
+
*/
|
|
190
|
+
onAnswer?: (peer: PeerId, text: string, answers: readonly Envelope[]) => boolean;
|
|
191
|
+
/** Host ledger authority; absent or unknown never permits invocation replay. */
|
|
192
|
+
canRetryInvocation?: (id: string) => boolean;
|
|
193
|
+
onInvocationUncertain?: (id: string) => void;
|
|
194
|
+
/** Validated transport receipts, ids only; application admission is not a capability result. */
|
|
195
|
+
onInvocationReceipt?: (id: string, state: "completed" | "failed_safe" | "needs_review") => void;
|
|
196
|
+
hasInvocationTurn(peer: PeerId): boolean { return this.invocationTurnOpen(peer); }
|
|
197
|
+
private readonly invocationDeliveries = new Map<string, string>();
|
|
198
|
+
|
|
199
|
+
releaseInvocation(id: string): void { this.invocationDeliveries.delete(id); }
|
|
200
|
+
invocationUncertain(id: string): boolean {
|
|
201
|
+
const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
|
|
202
|
+
return !!row && (row.state === "needs_review" || ["dispatching", "accepted"].includes(row.state) && !this.activeDeliveries.has(row.id));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Hold the existing delivery rather than cancelling native work or replaying an uncertain effect. */
|
|
206
|
+
holdInvocation(id: string, reason: string, target?: PeerId): void {
|
|
207
|
+
const delivery = this.invocationDeliveries.get(id), row = delivery && this.journal?.get(delivery);
|
|
208
|
+
if (!row || !this.journal) {
|
|
209
|
+
this.storageError = "uncertain invocation delivery evidence unavailable; inspect the invocation ledger before recovery";
|
|
210
|
+
if (target) this.recoveryHeldPeers.add(target);
|
|
211
|
+
this.onQueues?.();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.journal.transition(row.id, "needs_review", reason);
|
|
215
|
+
this.activeDeliveries.delete(row.id);
|
|
216
|
+
this.recoveryHeldPeers.add(row.peer);
|
|
217
|
+
this.onQueues?.();
|
|
218
|
+
}
|
|
219
|
+
|
|
180
220
|
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
181
221
|
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
182
222
|
/** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
|
|
@@ -208,6 +248,7 @@ export class Bus {
|
|
|
208
248
|
if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
|
|
209
249
|
for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
|
|
210
250
|
for (const row of this.journal.list()) {
|
|
251
|
+
for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
|
|
211
252
|
if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
|
|
212
253
|
}
|
|
213
254
|
}
|
|
@@ -243,6 +284,17 @@ export class Bus {
|
|
|
243
284
|
this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
|
|
244
285
|
peer.onMessage = (text, opts) => {
|
|
245
286
|
if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
|
|
287
|
+
// #445 C2: an answer to an invocation is found by what the peer was handed, never by how the answer is addressed, so a headless agent whose final answer is
|
|
288
|
+
// normally shared with every peer cannot leak it. It is the invocation's answer when it names the request itself (an adapter may report its delivery complete before
|
|
289
|
+
// it answers, so the receipt cannot decide), or when it names nothing while that invocation's own delivery is still the peer's open turn. Once that turn has ended,
|
|
290
|
+
// `lastDelivery` still holds the request until the next delivery replaces it, and a later message from the peer is an ordinary message: it must be published.
|
|
291
|
+
const handed = this.lastDelivery.get(peer.id)?.out ?? [];
|
|
292
|
+
const named = opts?.inReplyTo?.id !== undefined && handed.some((e) => e.id === opts.inReplyTo!.id); // an invocation is delivered alone, so the only envelope in `handed` to name is its request (or the preface that rode with it)
|
|
293
|
+
if (this.onAnswer && handed.some((e) => e.kind === "invocation") && (named || opts?.inReplyTo?.id === undefined && this.invocationTurnOpen(peer.id))) {
|
|
294
|
+
let taken = false;
|
|
295
|
+
try { taken = this.onAnswer(peer.id, text, handed) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
|
|
296
|
+
if (taken) return;
|
|
297
|
+
}
|
|
246
298
|
const { priority, body } = parseMarker(text);
|
|
247
299
|
if (!body) return;
|
|
248
300
|
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
@@ -311,6 +363,9 @@ export class Bus {
|
|
|
311
363
|
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
|
|
312
364
|
}
|
|
313
365
|
}
|
|
366
|
+
} else if (envs.some(env => env.kind === "invocation")) {
|
|
367
|
+
this.recoveryHeldPeers.add(peer.id);
|
|
368
|
+
for (const env of envs) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
|
|
314
369
|
} else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
|
|
315
370
|
};
|
|
316
371
|
peer.onState = () => {
|
|
@@ -490,6 +545,7 @@ export class Bus {
|
|
|
490
545
|
}
|
|
491
546
|
}
|
|
492
547
|
this.loadSnapshot(snapshot);
|
|
548
|
+
if (this.journal) for (const row of this.journal.list()) for (const env of row.originals) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, row.id);
|
|
493
549
|
this.persist();
|
|
494
550
|
}
|
|
495
551
|
|
|
@@ -557,6 +613,7 @@ export class Bus {
|
|
|
557
613
|
if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
|
|
558
614
|
const row = this.queueShow(id);
|
|
559
615
|
if (!row || row.peer !== peer || row.state !== "needs_review" || !this.journal || this.journal.retryEvidence(peer, row.originals) !== "unused") return refuse();
|
|
616
|
+
if (row.originals.some(env => env.kind === "invocation")) return refuse();
|
|
560
617
|
this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
|
|
561
618
|
}
|
|
562
619
|
|
|
@@ -671,6 +728,7 @@ export class Bus {
|
|
|
671
728
|
const last = this.lastDelivery.get(peer);
|
|
672
729
|
if (before && last && last.out.length === before.out.length && last.out.every((env, index) => env.id === before.out[index]!.id)) this.lastDelivery.delete(peer);
|
|
673
730
|
this.refreshRecoveryHold(peer);
|
|
731
|
+
for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, "failed_safe");
|
|
674
732
|
this.onQueues?.();
|
|
675
733
|
void this.drain(peer);
|
|
676
734
|
return;
|
|
@@ -689,7 +747,11 @@ export class Bus {
|
|
|
689
747
|
if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
|
|
690
748
|
if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
|
|
691
749
|
if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
|
|
692
|
-
if (state === "needs_review") this.
|
|
750
|
+
if (receipt.state === "completed" || receipt.state === "needs_review") for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, receipt.state);
|
|
751
|
+
if (state === "needs_review") {
|
|
752
|
+
this.recoveryHeldPeers.add(peer);
|
|
753
|
+
for (const env of before?.originals ?? []) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
|
|
754
|
+
}
|
|
693
755
|
this.onQueues?.();
|
|
694
756
|
}
|
|
695
757
|
|
|
@@ -797,7 +859,7 @@ export class Bus {
|
|
|
797
859
|
note(id: PeerId, line: string): void {
|
|
798
860
|
if (remoteId(id)) return;
|
|
799
861
|
const existing = this.prefaces.get(id);
|
|
800
|
-
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
862
|
+
this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), this.opts.notesKept?.() ?? NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
|
|
801
863
|
this.persist();
|
|
802
864
|
}
|
|
803
865
|
|
|
@@ -805,7 +867,7 @@ export class Bus {
|
|
|
805
867
|
private restorePreface(id: PeerId, env: Envelope): void {
|
|
806
868
|
if (remoteId(id)) return;
|
|
807
869
|
const now = this.prefaces.get(id);
|
|
808
|
-
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
870
|
+
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), this.opts.notesKept?.() ?? NOTES_KEPT) });
|
|
809
871
|
}
|
|
810
872
|
|
|
811
873
|
/**
|
|
@@ -1016,7 +1078,7 @@ export class Bus {
|
|
|
1016
1078
|
try { for (const id of targets) {
|
|
1017
1079
|
const peer = this.peers.get(id);
|
|
1018
1080
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
1019
|
-
if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy") {
|
|
1081
|
+
if (!this.journal && !this.held(id) && env.priority === "important" && !remoteId(id) && peer.steer && this.stateOf(id) === "busy" && !this.invocationTurnOpen(id)) {
|
|
1020
1082
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
1021
1083
|
this.steering++;
|
|
1022
1084
|
const deliveryId = crypto.randomUUID();
|
|
@@ -1036,7 +1098,7 @@ export class Bus {
|
|
|
1036
1098
|
if (!this.flushingHub) this.onQueues?.();
|
|
1037
1099
|
for (const id of targets) {
|
|
1038
1100
|
const peer = this.peers.get(id);
|
|
1039
|
-
if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
|
|
1101
|
+
if (!this.held(id) && env.priority === "important" && !remoteId(id) && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env); // steerQueued itself refuses to join an invocation's turn
|
|
1040
1102
|
else void this.drain(id);
|
|
1041
1103
|
}
|
|
1042
1104
|
}
|
|
@@ -1080,6 +1142,20 @@ export class Bus {
|
|
|
1080
1142
|
return this.seen.get(id) ?? this.deferredHub.get(id);
|
|
1081
1143
|
}
|
|
1082
1144
|
|
|
1145
|
+
/**
|
|
1146
|
+
* #445 C2: whether the peer's current turn is one that holds an invocation request. With a journal that is a delivery of the peer's own that has not reached a
|
|
1147
|
+
* terminal receipt and whose payload holds the request (the receipt paths delete it from `activeDeliveries`); without one it is the peer being busy, which is the
|
|
1148
|
+
* best the bus can know (the daemon always has a journal). A steer is never sent into such a turn (the two steer sites and `steerQueued` ask this first): an adapter answers the highest-hop envelope of
|
|
1149
|
+
* everything its turn holds, and a steered one would become what the answer names instead of the request.
|
|
1150
|
+
*/
|
|
1151
|
+
private invocationTurnOpen(peer: PeerId): boolean {
|
|
1152
|
+
if (!this.journal) return this.stateOf(peer) === "busy" && (this.lastDelivery.get(peer)?.out ?? []).some((e) => e.kind === "invocation");
|
|
1153
|
+
for (const [deliveryId, owner] of this.activeDeliveries) {
|
|
1154
|
+
if (owner === peer && this.journal.get(deliveryId)?.out.some((e) => e.kind === "invocation")) return true;
|
|
1155
|
+
}
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1083
1159
|
/** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
|
|
1084
1160
|
lastDeliveredTo(peer: PeerId): Envelope[] {
|
|
1085
1161
|
return this.lastDelivery.get(peer)?.out ?? [];
|
|
@@ -1305,6 +1381,8 @@ export class Bus {
|
|
|
1305
1381
|
const peer = this.peers.get(id);
|
|
1306
1382
|
const queue = this.queues.get(id);
|
|
1307
1383
|
if (!this.journal || !peer?.steer || !queue || this.storageError) return;
|
|
1384
|
+
// #445 C2: an invocation's turn answers the request alone. Joining it with a steer would change what its answer names, so the envelope waits in the queue for the next delivery.
|
|
1385
|
+
if (this.invocationTurnOpen(id)) { void this.drain(id); return; }
|
|
1308
1386
|
const index = queue.findIndex((item) => item.id === env.id);
|
|
1309
1387
|
if (index < 0) return;
|
|
1310
1388
|
this.steering++;
|
|
@@ -1326,6 +1404,9 @@ export class Bus {
|
|
|
1326
1404
|
}
|
|
1327
1405
|
|
|
1328
1406
|
/** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
|
|
1407
|
+
/** #445 C2: whether a delivery to this peer would wait on a hold (roster, recovery, pause or a lifecycle fence). */
|
|
1408
|
+
isHeld(id: PeerId): boolean { return this.held(id); }
|
|
1409
|
+
|
|
1329
1410
|
private held(id: PeerId): boolean {
|
|
1330
1411
|
return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
1331
1412
|
}
|
|
@@ -1445,6 +1526,7 @@ export class Bus {
|
|
|
1445
1526
|
this.activeDeliveries.set(deliveryId, id);
|
|
1446
1527
|
} else this.remember(out);
|
|
1447
1528
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
1529
|
+
for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
|
|
1448
1530
|
if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
|
|
1449
1531
|
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
1450
1532
|
try {
|
|
@@ -1458,7 +1540,10 @@ export class Bus {
|
|
|
1458
1540
|
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
1459
1541
|
} catch {
|
|
1460
1542
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
1461
|
-
else
|
|
1543
|
+
else if (delivery.some(env => env.kind === "invocation")) {
|
|
1544
|
+
this.recoveryHeldPeers.add(id);
|
|
1545
|
+
for (const env of delivery) if (env.kind === "invocation") this.onInvocationUncertain?.(env.id);
|
|
1546
|
+
} else this.failed(id, delivery);
|
|
1462
1547
|
break;
|
|
1463
1548
|
} finally { this.remoteTransport.delete(deliveryId); }
|
|
1464
1549
|
}
|
|
@@ -1505,13 +1590,16 @@ export class Bus {
|
|
|
1505
1590
|
* An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
|
|
1506
1591
|
*/
|
|
1507
1592
|
private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
|
|
1593
|
+
const digestMax = this.opts.digestMax?.() ?? DIGEST_MAX;
|
|
1508
1594
|
const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
|
|
1509
1595
|
const batch: Envelope[] = [];
|
|
1510
1596
|
for (const env of ordered) {
|
|
1511
1597
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1512
1598
|
if (failedBefore && batch.length) break;
|
|
1599
|
+
// #445 C2: an invocation or its result goes alone, so the reply to the turn answers exactly one of them.
|
|
1600
|
+
if (isInvocationKind(env) && batch.length) break;
|
|
1513
1601
|
batch.push(env);
|
|
1514
|
-
if (failedBefore || remoteId(id) || batch.length ===
|
|
1602
|
+
if (failedBefore || remoteId(id) || batch.length === digestMax || isInvocationKind(env)) break;
|
|
1515
1603
|
}
|
|
1516
1604
|
if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1517
1605
|
return batch;
|
|
@@ -1523,15 +1611,17 @@ export class Bus {
|
|
|
1523
1611
|
* envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
|
|
1524
1612
|
*/
|
|
1525
1613
|
private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
|
|
1614
|
+
const pullBudgetBytes = this.opts.pullBudgetBytes?.() ?? PULL_BUDGET_BYTES;
|
|
1526
1615
|
const batch: Envelope[] = [];
|
|
1527
1616
|
let bytes = 0;
|
|
1528
1617
|
for (const env of queue) {
|
|
1529
1618
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1530
1619
|
if (failedBefore && batch.length) break;
|
|
1531
1620
|
const size = Buffer.byteLength(frame(env)) + 2;
|
|
1532
|
-
if (batch.length && bytes + size >
|
|
1621
|
+
if (batch.length && bytes + size > pullBudgetBytes) break;
|
|
1622
|
+
if (isInvocationKind(env) && batch.length) break; // #445 C2: alone, as in `take`
|
|
1533
1623
|
batch.push(env); bytes += size;
|
|
1534
|
-
if (failedBefore) break;
|
|
1624
|
+
if (failedBefore || isInvocationKind(env)) break;
|
|
1535
1625
|
}
|
|
1536
1626
|
for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1537
1627
|
return batch;
|
|
@@ -1555,11 +1645,12 @@ export class Bus {
|
|
|
1555
1645
|
}
|
|
1556
1646
|
|
|
1557
1647
|
private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
|
|
1648
|
+
const maxAttempts = this.opts.maxAttempts?.() ?? MAX_ATTEMPTS;
|
|
1558
1649
|
if (!this.journal) this.deliveryFailed(id);
|
|
1559
1650
|
// A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
|
|
1560
1651
|
const quotaPaused = quota && this.paused.has(id);
|
|
1561
1652
|
this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
|
|
1562
|
-
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >=
|
|
1653
|
+
if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= maxAttempts)) {
|
|
1563
1654
|
this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
|
|
1564
1655
|
}
|
|
1565
1656
|
const keep = envs.filter((env) => {
|
|
@@ -1568,10 +1659,19 @@ export class Bus {
|
|
|
1568
1659
|
this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
|
|
1569
1660
|
return false;
|
|
1570
1661
|
}
|
|
1662
|
+
if (env.kind === "invocation") {
|
|
1663
|
+
const key = `${id}:${env.id}`, n = (this.attempts.get(key) ?? 0) + 1;
|
|
1664
|
+
let read = false;
|
|
1665
|
+
try { read = this.canRetryInvocation?.(env.id) === true; } catch { /* Unknown refuses replay. */ }
|
|
1666
|
+
if (read && n < 2) { this.attempts.set(key, n); return true; }
|
|
1667
|
+
this.attempts.delete(key);
|
|
1668
|
+
this.emit({ t: "undeliverable", env, peer: id, reason: "invocation safe delivery refusal; no further automatic retry" });
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1571
1671
|
if (quotaPaused) return true;
|
|
1572
1672
|
const key = `${id}:${env.id}`;
|
|
1573
1673
|
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
1574
|
-
if (n <
|
|
1674
|
+
if (n < maxAttempts) return this.attempts.set(key, n);
|
|
1575
1675
|
this.attempts.delete(key);
|
|
1576
1676
|
this.emit({ t: "undeliverable", env, peer: id, reason: this.lastFailure.get(id) });
|
|
1577
1677
|
return false;
|
|
@@ -1584,7 +1684,7 @@ export class Bus {
|
|
|
1584
1684
|
|
|
1585
1685
|
private emit(e: BusEvent): void {
|
|
1586
1686
|
if (this.flushingHub) { this.deferredEvents.push(e); return; }
|
|
1587
|
-
if ("env" in e && (
|
|
1687
|
+
if ("env" in e && (remoteEnvelope(e.env) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
|
|
1588
1688
|
const { refs: _, ...env } = e.env;
|
|
1589
1689
|
e = { ...e, env: { ...env, body: "[remote room message withheld]", ...(env.to ? { to: env.to.filter(id => /^[a-z][a-z0-9-]{0,31}$/.test(id) || !!parseRemotePeerId(id)) } : {}) }, ...("reason" in e ? { reason: "remote room delivery" } : {}) };
|
|
1590
1690
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { sanitize } from "./envelope.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #445 C1: what an agent can offer another, and what a person has agreed to share. Pure: no I/O, no clock, no process. The hub never calls any of these capabilities itself (C2 sends a request to the
|
|
6
|
+
* owner's own session); this module only decides what is LISTED.
|
|
7
|
+
*
|
|
8
|
+
* Authority: nothing is shared unless the person wrote it, in `invocation.share` of `.agenthub/config.local.json`, keyed by the exact peer id and the capability id and pinned by the capability's digest.
|
|
9
|
+
* Everything that comes from an agent (names, descriptions, schemas, the publisher's side-effect declaration) is untrusted text and only ever narrows what is shown.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SIDE_EFFECTS = ["read", "write", "external", "destructive"] as const;
|
|
13
|
+
export type SideEffect = (typeof SIDE_EFFECTS)[number];
|
|
14
|
+
export const CAPABILITY_KINDS = ["command", "skill", "mcp_tool"] as const;
|
|
15
|
+
export const CAPABILITY_SOURCES = ["acp", "codex-skills", "published"] as const;
|
|
16
|
+
export const CAPABILITY_INVOKES = ["native", "prompt"] as const;
|
|
17
|
+
|
|
18
|
+
export const NAME_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
19
|
+
export const DESCRIPTION_MAX = 300;
|
|
20
|
+
/** A listing is bounded: no source can make the hub hold or show an unbounded manifest. */
|
|
21
|
+
export const MAX_CAPABILITIES = 128;
|
|
22
|
+
const MAX_SCHEMA_PROPERTIES = 16;
|
|
23
|
+
const MAX_SCHEMA_ENUM = 32;
|
|
24
|
+
const MAX_ENUM_VALUE = 64;
|
|
25
|
+
const MAX_PUBLISH_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
export interface FlatSchema {
|
|
28
|
+
type: "object";
|
|
29
|
+
properties: Record<string, { type: "string" | "number" | "boolean"; enum?: string[]; description?: string }>;
|
|
30
|
+
required?: string[];
|
|
31
|
+
additionalProperties: false;
|
|
32
|
+
}
|
|
33
|
+
export interface Capability {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: (typeof CAPABILITY_KINDS)[number];
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
inputSchema?: FlatSchema;
|
|
39
|
+
/** The publisher's declared class; absent when its source names none (every native listing). */
|
|
40
|
+
sideEffect?: SideEffect;
|
|
41
|
+
source: (typeof CAPABILITY_SOURCES)[number];
|
|
42
|
+
invoke: (typeof CAPABILITY_INVOKES)[number];
|
|
43
|
+
digest: string;
|
|
44
|
+
}
|
|
45
|
+
export interface CapabilityManifest { capabilities: Capability[]; observedAt: number }
|
|
46
|
+
|
|
47
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
48
|
+
const CONTROL_CHARS = "[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]";
|
|
49
|
+
/** Two regexes on purpose: the global one replaces, the plain one tests (a global regex keeps lastIndex between test calls and would miss the second of two strings). */
|
|
50
|
+
const CONTROL = new RegExp(CONTROL_CHARS, "g"), HAS_CONTROL = new RegExp(CONTROL_CHARS);
|
|
51
|
+
|
|
52
|
+
/** Canonical JSON: sorted keys at every depth, so equal values digest equally whatever order a source wrote them in. */
|
|
53
|
+
export function canonical(value: unknown): string {
|
|
54
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
55
|
+
if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
56
|
+
return JSON.stringify(value) ?? "null";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The digest pins what a person agreed to: the identity of the capability, what it takes, the declared class, where it came from and how it is run. The description is NOT in it: it is capped untrusted text
|
|
61
|
+
* a source may reword freely, so rewording must not unlist an entry, while a changed schema, class, source or invoke form must.
|
|
62
|
+
*/
|
|
63
|
+
export function capabilityDigest(c: Pick<Capability, "kind" | "name" | "inputSchema" | "sideEffect" | "source" | "invoke">): string {
|
|
64
|
+
return `sha256:${createHash("sha256").update(canonical({ kind: c.kind, name: c.name, inputSchema: c.inputSchema ?? null, sideEffect: c.sideEffect ?? null, source: c.source, invoke: c.invoke })).digest("hex")}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Agent text shown to another model: control and bidirectional characters out, header forgery quoted, whitespace collapsed, cut by code point. */
|
|
68
|
+
export function cleanDescription(raw: unknown): string {
|
|
69
|
+
const text = typeof raw === "string" ? raw : "";
|
|
70
|
+
return Array.from(sanitize(text.replace(CONTROL, " ")).replace(/\s+/g, " ").trim()).slice(0, DESCRIPTION_MAX).join("");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A flat object of strings, numbers, booleans and string enums, nothing nested, nothing extra. Anything else is not a schema this hub will carry. */
|
|
74
|
+
export function flatSchema(raw: unknown): FlatSchema | undefined {
|
|
75
|
+
if (!object(raw) || raw.type !== "object" || raw.additionalProperties !== false || !object(raw.properties)) return undefined;
|
|
76
|
+
const names = Object.keys(raw.properties);
|
|
77
|
+
if (names.length === 0 || names.length > MAX_SCHEMA_PROPERTIES) return undefined;
|
|
78
|
+
const properties: FlatSchema["properties"] = {};
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const prop = raw.properties[name];
|
|
81
|
+
if (!NAME_RE.test(name) || !object(prop) || !["string", "number", "boolean"].includes(prop.type as string)) return undefined;
|
|
82
|
+
const extra = Object.keys(prop).filter(key => !["type", "enum", "description"].includes(key));
|
|
83
|
+
if (extra.length) return undefined;
|
|
84
|
+
const out: FlatSchema["properties"][string] = { type: prop.type as "string" | "number" | "boolean" };
|
|
85
|
+
if (prop.enum !== undefined) {
|
|
86
|
+
if (prop.type !== "string" || !Array.isArray(prop.enum) || prop.enum.length === 0 || prop.enum.length > MAX_SCHEMA_ENUM
|
|
87
|
+
|| !prop.enum.every(value => typeof value === "string" && value.length >= 1 && value.length <= MAX_ENUM_VALUE && !HAS_CONTROL.test(value))) return undefined;
|
|
88
|
+
out.enum = [...prop.enum] as string[];
|
|
89
|
+
}
|
|
90
|
+
if (prop.description !== undefined) out.description = cleanDescription(prop.description);
|
|
91
|
+
properties[name] = out;
|
|
92
|
+
}
|
|
93
|
+
let required: string[] | undefined;
|
|
94
|
+
if (raw.required !== undefined) {
|
|
95
|
+
if (!Array.isArray(raw.required) || !raw.required.every(name => typeof name === "string" && Object.hasOwn(properties, name))) return undefined;
|
|
96
|
+
required = [...new Set(raw.required as string[])];
|
|
97
|
+
}
|
|
98
|
+
return { type: "object", properties, ...(required?.length ? { required } : {}), additionalProperties: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A class a side wrote: absent stays absent (it contributes nothing, O6), but a value that is not one of the four reads as the most dangerous one. */
|
|
102
|
+
export function declaredClass(raw: unknown): SideEffect | undefined {
|
|
103
|
+
if (raw === undefined) return undefined;
|
|
104
|
+
return typeof raw === "string" && (SIDE_EFFECTS as readonly string[]).includes(raw) ? raw as SideEffect : "destructive";
|
|
105
|
+
}
|
|
106
|
+
const RANK: Record<SideEffect, number> = { read: 0, write: 1, external: 2, destructive: 3 };
|
|
107
|
+
/** The higher of the publisher's declaration and the person's class (owner decision O1): neither side can make a capability look safer than the other said. */
|
|
108
|
+
export function effectiveClass(declared: SideEffect | undefined, person: unknown): SideEffect {
|
|
109
|
+
const chosen = declaredClass(person);
|
|
110
|
+
if (declared === undefined) return chosen ?? "destructive"; // O6: only when neither side names a class
|
|
111
|
+
if (chosen === undefined) return declared;
|
|
112
|
+
return RANK[chosen] >= RANK[declared] ? chosen : declared;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Candidate { kind: unknown; name: unknown; description?: unknown; inputSchema?: unknown; sideEffect?: unknown; source: Capability["source"]; invoke: Capability["invoke"] }
|
|
116
|
+
/** One capability from one source, made safe, or undefined when it is not a capability this hub will carry. The id is derived, never taken from the source. */
|
|
117
|
+
export function buildCapability(candidate: Candidate): Capability | undefined {
|
|
118
|
+
if (!(CAPABILITY_KINDS as readonly unknown[]).includes(candidate.kind) || typeof candidate.name !== "string" || !NAME_RE.test(candidate.name)) return undefined;
|
|
119
|
+
let inputSchema: FlatSchema | undefined;
|
|
120
|
+
if (candidate.inputSchema !== undefined) { inputSchema = flatSchema(candidate.inputSchema); if (!inputSchema) return undefined; }
|
|
121
|
+
const kind = candidate.kind as Capability["kind"], sideEffect = declaredClass(candidate.sideEffect);
|
|
122
|
+
const base = { kind, name: candidate.name, ...(inputSchema ? { inputSchema } : {}), ...(sideEffect ? { sideEffect } : {}), source: candidate.source, invoke: candidate.invoke };
|
|
123
|
+
return { id: `${kind}:${candidate.name}`, ...base, description: cleanDescription(candidate.description), digest: capabilityDigest(base) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A manifest from a source's raw list: invalid entries are dropped one by one, duplicates by id keep the first, and the whole is bounded. */
|
|
127
|
+
export function buildManifest(raw: readonly Candidate[], observedAt: number): CapabilityManifest {
|
|
128
|
+
const seen = new Set<string>(), capabilities: Capability[] = [];
|
|
129
|
+
for (const candidate of raw) {
|
|
130
|
+
const built = buildCapability(candidate);
|
|
131
|
+
if (!built || seen.has(built.id)) continue;
|
|
132
|
+
seen.add(built.id); capabilities.push(built);
|
|
133
|
+
if (capabilities.length >= MAX_CAPABILITIES) break;
|
|
134
|
+
}
|
|
135
|
+
return { capabilities, observedAt };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class PublishRefusal extends Error {}
|
|
139
|
+
/**
|
|
140
|
+
* `hub_capabilities_publish`: the source for a session whose capabilities the hub cannot read natively. Every field is checked, an unknown field is a refusal, nothing is stored on a refusal, and the invoke
|
|
141
|
+
* form is forced to `prompt`: a publisher cannot claim a native path the hub never verified.
|
|
142
|
+
*/
|
|
143
|
+
export function validatePublished(raw: unknown, observedAt: number): CapabilityManifest {
|
|
144
|
+
if (!object(raw) || Object.keys(raw).some(key => key !== "capabilities") || !Array.isArray(raw.capabilities)) throw new PublishRefusal("capabilities must be an array");
|
|
145
|
+
if (raw.capabilities.length === 0) throw new PublishRefusal("capabilities must not be empty (publish a list, or nothing)");
|
|
146
|
+
if (raw.capabilities.length > MAX_CAPABILITIES) throw new PublishRefusal(`at most ${MAX_CAPABILITIES} capabilities`);
|
|
147
|
+
if (JSON.stringify(raw.capabilities).length > MAX_PUBLISH_BYTES) throw new PublishRefusal("the list is too large");
|
|
148
|
+
const seen = new Set<string>(), candidates: Candidate[] = [];
|
|
149
|
+
raw.capabilities.forEach((entry, index) => {
|
|
150
|
+
const at = `capabilities[${index}]`;
|
|
151
|
+
if (!object(entry)) throw new PublishRefusal(`${at} must be an object`);
|
|
152
|
+
const extra = Object.keys(entry).filter(key => !["kind", "name", "description", "inputSchema", "sideEffect"].includes(key));
|
|
153
|
+
if (extra.length) throw new PublishRefusal(`${at}: unknown field ${JSON.stringify(extra[0])}`);
|
|
154
|
+
if (!(CAPABILITY_KINDS as readonly unknown[]).includes(entry.kind)) throw new PublishRefusal(`${at}.kind must be one of ${CAPABILITY_KINDS.join(", ")}`);
|
|
155
|
+
if (typeof entry.name !== "string" || !NAME_RE.test(entry.name)) throw new PublishRefusal(`${at}.name must match ${NAME_RE}`);
|
|
156
|
+
if (typeof entry.description !== "string") throw new PublishRefusal(`${at}.description must be text`);
|
|
157
|
+
if (entry.sideEffect === undefined || !(SIDE_EFFECTS as readonly unknown[]).includes(entry.sideEffect)) throw new PublishRefusal(`${at}.sideEffect must be one of ${SIDE_EFFECTS.join(", ")}`);
|
|
158
|
+
if (entry.inputSchema !== undefined && !flatSchema(entry.inputSchema)) throw new PublishRefusal(`${at}.inputSchema must be a flat object of string, number, boolean or string enum properties with additionalProperties false`);
|
|
159
|
+
const id = `${entry.kind}:${entry.name}`;
|
|
160
|
+
if (seen.has(id)) throw new PublishRefusal(`${at}: ${id} is listed twice`);
|
|
161
|
+
seen.add(id);
|
|
162
|
+
candidates.push({ kind: entry.kind, name: entry.name, description: entry.description, inputSchema: entry.inputSchema, sideEffect: entry.sideEffect, source: "published", invoke: "prompt" });
|
|
163
|
+
});
|
|
164
|
+
return buildManifest(candidates, observedAt);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ShareEntry { digest: string; class?: SideEffect }
|
|
168
|
+
export type ShareConfig = Record<string, Record<string, ShareEntry>>;
|
|
169
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
170
|
+
/** `invocation.share` as written by a person: malformed entries are dropped and say nothing is shared for them; it is never an error that stops the hub. */
|
|
171
|
+
export function readShare(raw: unknown): ShareConfig {
|
|
172
|
+
const out: ShareConfig = {};
|
|
173
|
+
if (!object(raw)) return out;
|
|
174
|
+
for (const [peer, entries] of Object.entries(raw)) {
|
|
175
|
+
const address = /^[a-z][a-z0-9-]{0,31}(?:@[a-z][a-z0-9-]{0,31})?$/.exec(peer);
|
|
176
|
+
if (!object(entries) || address?.[0] !== peer || peer.includes("@") && ["hub", "digest"].includes(peer.split("@")[0]!)) continue;
|
|
177
|
+
const kept: Record<string, ShareEntry> = {};
|
|
178
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
179
|
+
if (!/^(command|skill|mcp_tool):[A-Za-z0-9_.:-]{1,64}$/.test(id) || !object(entry) || typeof entry.digest !== "string" || !DIGEST_RE.test(entry.digest)) continue;
|
|
180
|
+
const cls = entry.class === undefined ? undefined : (SIDE_EFFECTS as readonly unknown[]).includes(entry.class) ? entry.class as SideEffect : null;
|
|
181
|
+
if (cls === null || peer.includes("@") && cls !== "read") continue;
|
|
182
|
+
kept[id] = { digest: entry.digest, ...(cls ? { class: cls } : {}) };
|
|
183
|
+
}
|
|
184
|
+
if (Object.keys(kept).length) out[peer] = kept;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface Listed { id: string; kind: Capability["kind"]; name: string; description: string; inputSchema?: FlatSchema; class: SideEffect; invoke: Capability["invoke"]; source: Capability["source"]; digest: string }
|
|
190
|
+
/**
|
|
191
|
+
* What `peer` offers: the capabilities of its manifest that the person listed BY THIS PEER ID and whose digest still matches. A digest change drops the entry until the person pins the new one, and a peer id
|
|
192
|
+
* never reads another peer's entries (an instance `codex-2` does not inherit `codex`). With no share, or no manifest, the answer is nothing.
|
|
193
|
+
*/
|
|
194
|
+
export function listedCapabilities(peer: string, manifest: CapabilityManifest | undefined, share: ShareConfig): Listed[] {
|
|
195
|
+
const entries = Object.hasOwn(share, peer) ? share[peer] : undefined;
|
|
196
|
+
if (!manifest || !entries) return [];
|
|
197
|
+
const out: Listed[] = [];
|
|
198
|
+
for (const cap of manifest.capabilities) {
|
|
199
|
+
const entry = Object.hasOwn(entries, cap.id) ? entries[cap.id] : undefined;
|
|
200
|
+
if (!entry || entry.digest !== cap.digest) continue;
|
|
201
|
+
out.push({ id: cap.id, kind: cap.kind, name: cap.name, description: cap.description, ...(cap.inputSchema ? { inputSchema: cap.inputSchema } : {}), class: effectiveClass(cap.sideEffect, entry.class), invoke: cap.invoke, source: cap.source, digest: cap.digest });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type ShareState = "listed" | "unshared" | "digest-changed";
|
|
207
|
+
/** For the person's `ahub capabilities`: every manifest entry, whether the person shares it, and the class that would apply. */
|
|
208
|
+
export function shareView(peer: string, manifest: CapabilityManifest | undefined, share: ShareConfig): Array<{ id: string; kind: Capability["kind"]; name: string; description: string; source: Capability["source"]; invoke: Capability["invoke"]; digest: string; state: ShareState; class: SideEffect }> {
|
|
209
|
+
const entries = Object.hasOwn(share, peer) ? share[peer] : undefined;
|
|
210
|
+
return (manifest?.capabilities ?? []).map(cap => {
|
|
211
|
+
const entry = entries && Object.hasOwn(entries, cap.id) ? entries[cap.id] : undefined;
|
|
212
|
+
const state: ShareState = !entry ? "unshared" : entry.digest === cap.digest ? "listed" : "digest-changed";
|
|
213
|
+
return { id: cap.id, kind: cap.kind, name: cap.name, description: cap.description, source: cap.source, invoke: cap.invoke, digest: cap.digest, state, class: effectiveClass(cap.sideEffect, entry?.class) };
|
|
214
|
+
});
|
|
215
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -194,7 +194,11 @@ const schema: Record<string, Check> = {
|
|
|
194
194
|
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
195
195
|
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
196
196
|
context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
|
|
197
|
-
|
|
197
|
+
bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit") }),
|
|
198
|
+
adapter_timing: block({ acp_tool_ceiling_min: operationalNumberCheck("adapter_timing.acp_tool_ceiling_min"), acp_activity_batch_ms: operationalNumberCheck("adapter_timing.acp_activity_batch_ms"), jcode_steer_timeout_s: operationalNumberCheck("adapter_timing.jcode_steer_timeout_s") }),
|
|
199
|
+
model_relay: block({ cooldown_failures: operationalNumberCheck("model_relay.cooldown_failures"), cooldown_s: operationalNumberCheck("model_relay.cooldown_s"), cooldown_cap_s: operationalNumberCheck("model_relay.cooldown_cap_s"), terminal_history_limit: operationalNumberCheck("model_relay.terminal_history_limit") }),
|
|
200
|
+
console: block({ poll_ms: operationalNumberCheck("console.poll_ms"), notice_ms: operationalNumberCheck("console.notice_ms"), history_limit: operationalNumberCheck("console.history_limit"), pending_stream_limit: operationalNumberCheck("console.pending_stream_limit"), attention_limit: operationalNumberCheck("console.attention_limit") }),
|
|
201
|
+
inference: block({ enabled: bool, digest_min_items: operationalNumberCheck("inference.digest_min_items"), digest_min_chars: operationalNumberCheck("inference.digest_min_chars"), triage: bool, backoff_min: operationalNumberCheck("inference.backoff_min") }),
|
|
198
202
|
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
199
203
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
200
204
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
@@ -213,8 +217,8 @@ const schema: Record<string, Check> = {
|
|
|
213
217
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
214
218
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
215
219
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
216
|
-
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
217
|
-
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
220
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
|
|
221
|
+
capabilities: map(strings), invocation: block({ share: map(map(() => {})), timeout_s: number(10, 86_400, true) }), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
218
222
|
};
|
|
219
223
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
220
224
|
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
|
|
@@ -255,7 +259,7 @@ const restart: Record<string, string> = {
|
|
|
255
259
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
256
260
|
text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
257
261
|
};
|
|
258
|
-
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
|
|
262
|
+
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
|
|
259
263
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
260
264
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
261
265
|
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -33,6 +33,7 @@ export const MACHINE_LOCAL = [
|
|
|
33
33
|
"folder_triggers", // #512c: entries make the hub read a folder and propose a task per new file; a committed config may not point the hub at a folder on someone else's machine
|
|
34
34
|
"schedules", // #512: entries make the hub propose tasks on a timer; a committed config may not create work on someone else's machine
|
|
35
35
|
"corpus.paths", // #510: folders the hub reads as evidence; a committed config may not point the hub at a folder on someone else's machine
|
|
36
|
+
"invocation", // #445 C1: what another agent may be offered from this machine's agents (a digest-pinned allowlist); a committed config may not share a person's agents' commands with someone else's peers
|
|
36
37
|
] as const;
|
|
37
38
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
38
39
|
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|