@staix/agent-hub 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -1
- package/docs/agent-notes/daemon.md +3 -2
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -1
- package/docs/agent-notes/tasks.md +1 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +7 -1
- package/docs/operations.md +147 -6
- package/docs/profiles.md +28 -3
- package/docs/quickstart.md +5 -1
- package/docs/security.md +7 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +66 -4
- package/docs/verification/2026-10-30-0.23.0.md +70 -0
- package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
- package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
- package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
- package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
- package/docs/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 +2 -1
- package/src/cli/identity.ts +1 -0
- package/src/cli/main.ts +8 -0
- package/src/hub/board.ts +8 -2
- package/src/hub/bus.ts +100 -8
- package/src/hub/capabilities.ts +215 -0
- package/src/hub/config-apply.ts +6 -3
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +41 -8
- package/src/hub/daemon.ts +376 -20
- package/src/hub/deferred-hub.ts +2 -2
- package/src/hub/delivery-hints.ts +2 -2
- package/src/hub/delivery-journal.ts +2 -4
- package/src/hub/envelope.ts +12 -2
- package/src/hub/events.ts +2 -0
- package/src/hub/hub-tools.ts +16 -1
- package/src/hub/inference.ts +10 -5
- package/src/hub/invocations.ts +316 -0
- package/src/hub/operational-settings.ts +102 -1
- package/src/hub/peers.ts +3 -0
- package/src/hub/restart.ts +2 -2
- package/src/hub/room-invocations.ts +145 -0
- package/src/hub/settings.ts +1 -1
- package/src/hub/shipped-packs.ts +5 -2
- package/src/hub/shipped-roles.ts +13 -0
- package/src/hub/tasks.ts +14 -0
- package/src/memory/capture.ts +2 -2
- package/src/models/relay.ts +20 -9
- package/src/rooms/application.ts +67 -2
- package/src/rooms/boundary.ts +43 -11
- package/src/rooms/service.ts +40 -2
- package/templates/checks/analysis.mjs +59 -0
- package/templates/checks/fixtures/analysis-expected.json +1 -0
- package/templates/checks/fixtures/analysis-input.json +1 -0
- package/templates/checks/fixtures/analysis.mjs +7 -0
- package/templates/checks/fixtures/ops.json +4 -0
- package/templates/checks/fixtures/writing-rules.json +4 -0
- package/templates/checks/fixtures/writing.json +5 -0
- package/templates/checks/ops.mjs +40 -0
- package/templates/checks/writing.mjs +40 -0
- package/templates/packs/analysis.pack.json +14 -0
- package/templates/packs/ops.pack.json +14 -0
- package/templates/packs/writing-pack.pack.json +14 -0
- package/templates/roles/NOTICE.md +1 -1
- package/templates/roles/ops/meeting-actions-owner.md +26 -0
- package/templates/roles/ops/project-manager.md +26 -0
- package/templates/roles/writing/editor.md +26 -0
- package/templates/roles/writing/style-checker.md +26 -0
- package/templates/roles/writing/terminology-checker.md +26 -0
- package/templates/roles/writing/translator.md +26 -0
package/src/hub/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";
|
|
@@ -182,6 +182,41 @@ export class Bus {
|
|
|
182
182
|
* and is never corrected once the queue empties (measured: status.json kept `queued 4` on an empty queue).
|
|
183
183
|
*/
|
|
184
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
|
+
|
|
185
220
|
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
186
221
|
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
187
222
|
/** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
|
|
@@ -213,6 +248,7 @@ export class Bus {
|
|
|
213
248
|
if (state.revision > 0 && bus && bus.schemaVersion === 1) this.loadSnapshot(bus);
|
|
214
249
|
for (const id of state.manualPaused) { this.manualPaused.add(id); this.paused.add(id); }
|
|
215
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);
|
|
216
252
|
if (row.state === "needs_review" || row.state === "dispatching" || row.state === "accepted") this.recoveryHeldPeers.add(row.peer);
|
|
217
253
|
}
|
|
218
254
|
}
|
|
@@ -248,6 +284,17 @@ export class Bus {
|
|
|
248
284
|
this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
|
|
249
285
|
peer.onMessage = (text, opts) => {
|
|
250
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
|
+
}
|
|
251
298
|
const { priority, body } = parseMarker(text);
|
|
252
299
|
if (!body) return;
|
|
253
300
|
// What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
|
|
@@ -316,6 +363,9 @@ export class Bus {
|
|
|
316
363
|
this.deliveryReceipt(peer.id, { id: deliveryId, state: "needs_review", reason: "adapter reported delivery failure without safe-failure evidence" });
|
|
317
364
|
}
|
|
318
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);
|
|
319
369
|
} else this.failed(peer.id, same ? last.originals : envs, undefined, failure?.quota === true);
|
|
320
370
|
};
|
|
321
371
|
peer.onState = () => {
|
|
@@ -495,6 +545,7 @@ export class Bus {
|
|
|
495
545
|
}
|
|
496
546
|
}
|
|
497
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);
|
|
498
549
|
this.persist();
|
|
499
550
|
}
|
|
500
551
|
|
|
@@ -562,6 +613,7 @@ export class Bus {
|
|
|
562
613
|
if (this.closed || this.recoveryHeld || this.storageError || peer === "user" || peer === HUB) return refuse();
|
|
563
614
|
const row = this.queueShow(id);
|
|
564
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();
|
|
565
617
|
this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
|
|
566
618
|
}
|
|
567
619
|
|
|
@@ -676,6 +728,7 @@ export class Bus {
|
|
|
676
728
|
const last = this.lastDelivery.get(peer);
|
|
677
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);
|
|
678
730
|
this.refreshRecoveryHold(peer);
|
|
731
|
+
for (const env of before?.originals ?? []) if (isInvocationKind(env)) this.onInvocationReceipt?.(env.id, "failed_safe");
|
|
679
732
|
this.onQueues?.();
|
|
680
733
|
void this.drain(peer);
|
|
681
734
|
return;
|
|
@@ -694,7 +747,11 @@ export class Bus {
|
|
|
694
747
|
if (state === "completed") { this.failureStreak.delete(peer); this.lastFailure.delete(peer); }
|
|
695
748
|
if (state === "completed" || state === "failed" || state === "needs_review") this.activeDeliveries.delete(id);
|
|
696
749
|
if (state === "completed" || state === "failed") { this.refreshRecoveryHold(peer); void this.drain(peer); }
|
|
697
|
-
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
|
+
}
|
|
698
755
|
this.onQueues?.();
|
|
699
756
|
}
|
|
700
757
|
|
|
@@ -1021,7 +1078,7 @@ export class Bus {
|
|
|
1021
1078
|
try { for (const id of targets) {
|
|
1022
1079
|
const peer = this.peers.get(id);
|
|
1023
1080
|
if (!peer) { this.enqueue(id, env); continue; }
|
|
1024
|
-
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)) {
|
|
1025
1082
|
// Not queued while the steer is in flight, or an idle transition would deliver it a second time.
|
|
1026
1083
|
this.steering++;
|
|
1027
1084
|
const deliveryId = crypto.randomUUID();
|
|
@@ -1041,7 +1098,7 @@ export class Bus {
|
|
|
1041
1098
|
if (!this.flushingHub) this.onQueues?.();
|
|
1042
1099
|
for (const id of targets) {
|
|
1043
1100
|
const peer = this.peers.get(id);
|
|
1044
|
-
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
|
|
1045
1102
|
else void this.drain(id);
|
|
1046
1103
|
}
|
|
1047
1104
|
}
|
|
@@ -1085,6 +1142,20 @@ export class Bus {
|
|
|
1085
1142
|
return this.seen.get(id) ?? this.deferredHub.get(id);
|
|
1086
1143
|
}
|
|
1087
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
|
+
|
|
1088
1159
|
/** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
|
|
1089
1160
|
lastDeliveredTo(peer: PeerId): Envelope[] {
|
|
1090
1161
|
return this.lastDelivery.get(peer)?.out ?? [];
|
|
@@ -1310,6 +1381,8 @@ export class Bus {
|
|
|
1310
1381
|
const peer = this.peers.get(id);
|
|
1311
1382
|
const queue = this.queues.get(id);
|
|
1312
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; }
|
|
1313
1386
|
const index = queue.findIndex((item) => item.id === env.id);
|
|
1314
1387
|
if (index < 0) return;
|
|
1315
1388
|
this.steering++;
|
|
@@ -1331,6 +1404,9 @@ export class Bus {
|
|
|
1331
1404
|
}
|
|
1332
1405
|
|
|
1333
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
|
+
|
|
1334
1410
|
private held(id: PeerId): boolean {
|
|
1335
1411
|
return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
|
|
1336
1412
|
}
|
|
@@ -1450,6 +1526,7 @@ export class Bus {
|
|
|
1450
1526
|
this.activeDeliveries.set(deliveryId, id);
|
|
1451
1527
|
} else this.remember(out);
|
|
1452
1528
|
this.lastDelivery.set(id, { out, originals: delivery });
|
|
1529
|
+
for (const env of delivery) if (env.kind === "invocation") this.invocationDeliveries.set(env.id, deliveryId);
|
|
1453
1530
|
if (remoteId(id)) this.remoteTransport.set(deliveryId, { peer: id, originals: delivery });
|
|
1454
1531
|
this.preparing.delete(id); // from here on the transport/journal owns settlement
|
|
1455
1532
|
try {
|
|
@@ -1463,7 +1540,10 @@ export class Bus {
|
|
|
1463
1540
|
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
1464
1541
|
} catch {
|
|
1465
1542
|
if (this.journal) this.uncertain(id, deliveryId, "adapter delivery outcome is uncertain");
|
|
1466
|
-
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);
|
|
1467
1547
|
break;
|
|
1468
1548
|
} finally { this.remoteTransport.delete(deliveryId); }
|
|
1469
1549
|
}
|
|
@@ -1516,8 +1596,10 @@ export class Bus {
|
|
|
1516
1596
|
for (const env of ordered) {
|
|
1517
1597
|
const failedBefore = this.attempts.has(`${id}:${env.id}`);
|
|
1518
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;
|
|
1519
1601
|
batch.push(env);
|
|
1520
|
-
if (failedBefore || remoteId(id) || batch.length === digestMax) break;
|
|
1602
|
+
if (failedBefore || remoteId(id) || batch.length === digestMax || isInvocationKind(env)) break;
|
|
1521
1603
|
}
|
|
1522
1604
|
if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1523
1605
|
return batch;
|
|
@@ -1537,8 +1619,9 @@ export class Bus {
|
|
|
1537
1619
|
if (failedBefore && batch.length) break;
|
|
1538
1620
|
const size = Buffer.byteLength(frame(env)) + 2;
|
|
1539
1621
|
if (batch.length && bytes + size > pullBudgetBytes) break;
|
|
1622
|
+
if (isInvocationKind(env) && batch.length) break; // #445 C2: alone, as in `take`
|
|
1540
1623
|
batch.push(env); bytes += size;
|
|
1541
|
-
if (failedBefore) break;
|
|
1624
|
+
if (failedBefore || isInvocationKind(env)) break;
|
|
1542
1625
|
}
|
|
1543
1626
|
for (const env of batch) queue.splice(queue.indexOf(env), 1);
|
|
1544
1627
|
return batch;
|
|
@@ -1576,6 +1659,15 @@ export class Bus {
|
|
|
1576
1659
|
this.restorePreface(id, env); // rides again on the next delivery, without counting as a failed envelope
|
|
1577
1660
|
return false;
|
|
1578
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
|
+
}
|
|
1579
1671
|
if (quotaPaused) return true;
|
|
1580
1672
|
const key = `${id}:${env.id}`;
|
|
1581
1673
|
const n = (this.attempts.get(key) ?? 0) + 1;
|
|
@@ -1592,7 +1684,7 @@ export class Bus {
|
|
|
1592
1684
|
|
|
1593
1685
|
private emit(e: BusEvent): void {
|
|
1594
1686
|
if (this.flushingHub) { this.deferredEvents.push(e); return; }
|
|
1595
|
-
if ("env" in e && (
|
|
1687
|
+
if ("env" in e && (remoteEnvelope(e.env) || e.env.to?.some(remoteId) || ("peer" in e && remoteId(e.peer)))) {
|
|
1596
1688
|
const { refs: _, ...env } = e.env;
|
|
1597
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" } : {}) };
|
|
1598
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
|
@@ -195,7 +195,10 @@ const schema: Record<string, Check> = {
|
|
|
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
|
-
|
|
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") }),
|
|
199
202
|
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
200
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,
|
|
201
204
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
@@ -215,7 +218,7 @@ const schema: Record<string, Check> = {
|
|
|
215
218
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
216
219
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
217
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") }),
|
|
218
|
-
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
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"]) }),
|
|
219
222
|
};
|
|
220
223
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
221
224
|
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
|
|
@@ -256,7 +259,7 @@ const restart: Record<string, string> = {
|
|
|
256
259
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
257
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",
|
|
258
261
|
};
|
|
259
|
-
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"]);
|
|
260
263
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
261
264
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
262
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;
|
|
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
9
9
|
|
|
10
10
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
11
|
export const PROTOCOL = 16;
|
|
12
|
-
export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
12
|
+
export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
13
13
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
14
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
15
15
|
|
|
@@ -86,15 +86,25 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
|
|
|
86
86
|
|
|
87
87
|
const instancePins = new Map<string, { instanceId: string }>();
|
|
88
88
|
|
|
89
|
+
/** Maximum supported 3600 s approval plus admission/transport margin; applies to every hub_invoke client path. */
|
|
90
|
+
export const INVOCATION_CONTROL_TIMEOUT_MS = 3_665_000;
|
|
91
|
+
export function controlRequestTimeout(msg: Record<string, unknown>, requested = 30_000): number {
|
|
92
|
+
return msg.t === "task" && msg.op === "hub_invoke" && requested !== 0
|
|
93
|
+
? Math.max(Number.isFinite(requested) ? requested : 0, INVOCATION_CONTROL_TIMEOUT_MS) : requested;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
89
97
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
90
98
|
export class ControlClient {
|
|
91
99
|
features: readonly string[] = [];
|
|
92
100
|
private welcomeInstanceId?: string;
|
|
93
101
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
94
102
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
95
|
-
get isOpen(): boolean { return this.ws.readyState === WebSocket.OPEN; }
|
|
103
|
+
get isOpen(): boolean { return !this.closing && this.ws.readyState === WebSocket.OPEN; }
|
|
96
104
|
private nextRid = 1;
|
|
97
105
|
private readonly pending = new Map<number, (msg: any) => void>();
|
|
106
|
+
private readonly invocations = new Set<number>();
|
|
107
|
+
private closing = false;
|
|
98
108
|
onPush: (msg: any) => void = () => {};
|
|
99
109
|
/** `code`/`reason`: what the hub closed with, e.g. 4000 when another client attached as the same peer. */
|
|
100
110
|
onClose: (code: number, reason: string) => void = () => {};
|
|
@@ -179,13 +189,32 @@ export class ControlClient {
|
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
/** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
|
|
182
|
-
request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
|
|
183
|
-
if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
192
|
+
request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
|
|
193
|
+
if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
194
|
+
const invocation = msg.t === "task" && msg.op === "hub_invoke";
|
|
195
|
+
timeoutMs = controlRequestTimeout(msg, timeoutMs);
|
|
196
|
+
if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
|
|
184
197
|
const rid = this.nextRid++;
|
|
185
198
|
return new Promise((resolve) => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
const done = (reply: any) => {
|
|
201
|
+
this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
|
|
202
|
+
};
|
|
203
|
+
const abandon = (error: string) => {
|
|
204
|
+
if (!this.pending.has(rid)) return;
|
|
205
|
+
if (invocation && this.ws.readyState === WebSocket.OPEN) {
|
|
206
|
+
try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
|
|
207
|
+
}
|
|
208
|
+
done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
209
|
+
};
|
|
210
|
+
const aborted = () => abandon("hub request cancelled");
|
|
211
|
+
this.pending.set(rid, done);
|
|
212
|
+
if (invocation) this.invocations.add(rid);
|
|
213
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
214
|
+
if (signal?.aborted) { aborted(); return; }
|
|
215
|
+
timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
|
|
216
|
+
try { this.ws.send(JSON.stringify({ ...msg, rid })); }
|
|
217
|
+
catch { abandon("hub request could not be sent"); }
|
|
189
218
|
});
|
|
190
219
|
}
|
|
191
220
|
|
|
@@ -194,6 +223,10 @@ export class ControlClient {
|
|
|
194
223
|
}
|
|
195
224
|
|
|
196
225
|
close(): void {
|
|
197
|
-
this.
|
|
226
|
+
if (this.closing) return;
|
|
227
|
+
this.closing = true;
|
|
228
|
+
if (!this.invocations.size || this.ws.readyState !== WebSocket.OPEN) { this.ws.close(); return; }
|
|
229
|
+
// Withdraw admissions before reporting a graceful close to callers. An abrupt transport loss is fenced by the daemon close handler.
|
|
230
|
+
void Promise.allSettled([...this.invocations].map(request => this.request({ t: "request_cancel", request }, 3000))).finally(() => this.ws.close());
|
|
198
231
|
}
|
|
199
232
|
}
|