@staix/agent-hub 0.30.0 → 0.31.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 +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/memory/capture.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { reviewCaptureExcluded } from "../hub/memory-policy.ts";
|
|
1
2
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
3
|
import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
|
|
3
|
-
import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
|
|
4
|
+
import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, QUESTION_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
|
|
4
5
|
import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
|
|
5
6
|
import { isDenied } from "../local/deny.ts";
|
|
6
7
|
import type { MemoryClient } from "./client.ts";
|
|
@@ -38,7 +39,9 @@ export class Capture {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
|
|
41
|
-
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || this.opts.skip?.includes(call.tool)) return;
|
|
42
|
+
if (!this.sessionId || reviewCaptureExcluded(call.tool, call.args) || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_evidence_record" || this.opts.skip?.includes(call.tool)) return;
|
|
43
|
+
// #1053: a question and its answer are the person's and the peer's words: never memory evidence, whatever the call's paths say.
|
|
44
|
+
if (QUESTION_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) return;
|
|
42
45
|
// Anything that names a denylisted path is kept out of memory entirely, input and output alike.
|
|
43
46
|
if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
|
|
44
47
|
// ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
|
package/src/pet/config.ts
CHANGED
|
@@ -8,7 +8,14 @@ const PALETTES: Record<string, PetPalette> = { a: "blue", b: "teal", c: "violet"
|
|
|
8
8
|
export function petPalette(value: unknown): PetPalette | undefined {
|
|
9
9
|
return typeof value === "string" ? PALETTES[value.toLowerCase()] : undefined;
|
|
10
10
|
}
|
|
11
|
-
export
|
|
11
|
+
export const PET_WINDOW_RETIRED_NOTICE = "The floating pet window moved to acon Mini; run acon ui and open Mini.";
|
|
12
|
+
/** Source compatibility only. Removed window data is never a terminal option or a settings capability. */
|
|
13
|
+
export function stripLegacyPetWindow(value: unknown): unknown {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "window")) return value;
|
|
15
|
+
const { window: _retired, ...terminal } = value as Record<string, unknown>;
|
|
16
|
+
return terminal;
|
|
17
|
+
}
|
|
18
|
+
export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string }
|
|
12
19
|
/** Console-local presentation only; it merges pet.palette into config.local.json and grants no authority. */
|
|
13
20
|
export function savePetPalette(cwd: string, value: string): string {
|
|
14
21
|
const palette = petPalette(value);
|
|
@@ -35,34 +42,39 @@ export function savePetPalette(cwd: string, value: string): string {
|
|
|
35
42
|
/** Console-local presentation only; this reader never writes config or grants authority. */
|
|
36
43
|
export function loadPetConfig(cwd: string): PetConfig {
|
|
37
44
|
let value: unknown;
|
|
38
|
-
let enabled = true;
|
|
45
|
+
let enabled = true, retiredWindow = false;
|
|
39
46
|
for (const name of ["config.json", "config.local.json"]) {
|
|
40
47
|
try {
|
|
41
48
|
const file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
42
|
-
|
|
43
|
-
if (file.pet
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const pet = stripLegacyPetWindow(file.pet);
|
|
50
|
+
if (pet !== file.pet) {
|
|
51
|
+
retiredWindow = true;
|
|
52
|
+
// A retired-only block contributes no terminal override, including no enablement over inherited pet:false.
|
|
53
|
+
if (pet && typeof pet === "object" && !Object.keys(pet).length) continue;
|
|
54
|
+
}
|
|
55
|
+
if (pet === false) enabled = false;
|
|
56
|
+
else if (pet !== undefined) enabled = true;
|
|
57
|
+
if (pet !== undefined) value = value && typeof value === "object" && pet && typeof pet === "object" ? { ...value, ...pet } : pet;
|
|
46
58
|
} catch (error) {
|
|
47
59
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
|
-
|
|
62
|
+
const notice = (other?: string) => [retiredWindow ? PET_WINDOW_RETIRED_NOTICE : undefined, other].filter(Boolean).join(" ");
|
|
63
|
+
if (value === false) return { enabled: false, blink: true, bell: false, ...(retiredWindow ? { notice: notice() } : {}) };
|
|
51
64
|
const defaults = { enabled: true, blink: true, bell: false };
|
|
52
|
-
if (value === undefined) return defaults;
|
|
53
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: "Unknown pet configuration; using stai line defaults." };
|
|
65
|
+
if (value === undefined) return { ...defaults, ...(retiredWindow ? { notice: notice() } : {}) };
|
|
66
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: notice("Unknown pet configuration; using stai line defaults.") };
|
|
54
67
|
const row = value as Record<string, unknown>;
|
|
55
|
-
const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette"
|
|
68
|
+
const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette"].includes(key)) ||
|
|
56
69
|
row.character !== undefined && !characterId(row.character) || row.size !== undefined && !["line", "mini", "full"].includes(row.size as string) ||
|
|
57
70
|
row.glyphs !== undefined && (typeof row.glyphs !== "string" || !["auto", "ascii", "blocks"].includes(row.glyphs)) ||
|
|
58
71
|
row.palette !== undefined && !petPalette(row.palette) ||
|
|
59
|
-
row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean"
|
|
72
|
+
row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean";
|
|
60
73
|
const palette = petPalette(row.palette);
|
|
61
74
|
return { enabled, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
|
|
62
|
-
...(typeof row.window === "boolean" ? { window: row.window } : {}),
|
|
63
75
|
...(characterId(row.character) && row.character !== "stai" ? { character: row.character } : {}),
|
|
64
76
|
...(row.size === "mini" || row.size === "full" ? { size: row.size } : {}),
|
|
65
77
|
...(row.glyphs === "ascii" || row.glyphs === "blocks" ? { glyphs: row.glyphs } : {}),
|
|
66
78
|
...(palette && palette !== "teal" ? { palette } : {}),
|
|
67
|
-
...(invalid ? { notice: "Unknown pet option; using stai line defaults for that option." } : {}) };
|
|
79
|
+
...(invalid || retiredWindow ? { notice: notice(invalid ? "Unknown pet option; using stai line defaults for that option." : undefined) } : {}) };
|
|
68
80
|
}
|
package/src/rooms/link.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "..
|
|
|
3
3
|
import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
|
|
4
4
|
import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
|
|
5
5
|
import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
|
|
6
|
-
import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, type RoomOutgoingInput } from "./store.ts";
|
|
6
|
+
import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
|
|
7
7
|
import { decodeRoomMessage, encodeRoomMessage, type RoomMessage } from "./application.ts";
|
|
8
8
|
|
|
9
9
|
export type RoomLinkState = "offline" | "connecting" | "online";
|
|
@@ -28,6 +28,7 @@ export interface RoomLinkApplication {
|
|
|
28
28
|
stored(context: RoomLinkContext, input: { digest: string; seq: string }): void;
|
|
29
29
|
/** Fixed transport metadata only; never application completion or authority. */
|
|
30
30
|
transportAck?(context: RoomLinkContext, input: { digest: string }): void;
|
|
31
|
+
receiptTtlMs?(context: RoomLinkContext): number;
|
|
31
32
|
capacityChanged?(context: RoomLinkContext): void;
|
|
32
33
|
gap?(context: RoomLinkContext, input: { sender: string; count: string }): void;
|
|
33
34
|
}
|
|
@@ -40,12 +41,31 @@ export class RoomLinkError extends Error {
|
|
|
40
41
|
super(`Room link: ${code}`); this.name = "RoomLinkError";
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
/** Only before durable application enqueue: the Bus may retain its original queue item. */
|
|
45
|
+
export class RoomNotSentError extends Error {
|
|
46
|
+
readonly code = "not_sent";
|
|
47
|
+
constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
|
|
48
|
+
}
|
|
49
|
+
const RECEIPT_TTL_MS = 24 * 3_600_000;
|
|
43
50
|
const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
|
|
44
51
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
45
52
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
46
53
|
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
47
54
|
const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
|
|
55
|
+
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
|
|
48
56
|
interface Session {
|
|
57
|
+
processingFrame?: string;
|
|
58
|
+
acknowledgedFrame?: string;
|
|
59
|
+
pendingPresenceReplies: Set<string>;
|
|
60
|
+
presenceAsked: boolean;
|
|
61
|
+
presenceRoster?: string;
|
|
62
|
+
outbound: PendingFrame[];
|
|
63
|
+
outboundBytes: number;
|
|
64
|
+
frameSent: boolean;
|
|
65
|
+
retryAttempt: number;
|
|
66
|
+
frameRetry?: ReturnType<typeof setTimeout>;
|
|
67
|
+
lastPresence?: string;
|
|
68
|
+
ephemeral?: boolean;
|
|
49
69
|
ws: WebSocket;
|
|
50
70
|
generation: number;
|
|
51
71
|
room: string;
|
|
@@ -165,6 +185,7 @@ export class RoomLink {
|
|
|
165
185
|
if (!this.publicationCurrent(session, row.current.digest) || !this.options.store.ordinaryInboxAvailable(this.options.name, bytes)) return;
|
|
166
186
|
// Intent survives coalescing, ordinary responses and publication failure.
|
|
167
187
|
// Only an actually sent request with current capacity may clear the numeric flag.
|
|
188
|
+
if (!this.forcePresenceRequest) { session.pendingPresenceReplies.clear(); session.presenceAsked = false; }
|
|
168
189
|
session.requestPresenceReply = true; this.forcePresenceRequest = true;
|
|
169
190
|
try { await this.publishPresence(); }
|
|
170
191
|
catch (error) { this.retry(session); throw error; }
|
|
@@ -233,6 +254,8 @@ export class RoomLink {
|
|
|
233
254
|
this.republish = undefined;
|
|
234
255
|
if (session) {
|
|
235
256
|
clearTimeout(session.handshake);
|
|
257
|
+
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
258
|
+
session.outbound.length = 0; session.outboundBytes = 0; session.pendingPresenceReplies.clear();
|
|
236
259
|
session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
|
|
237
260
|
try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
|
|
238
261
|
}
|
|
@@ -259,10 +282,11 @@ export class RoomLink {
|
|
|
259
282
|
} catch { this.setState("offline"); return; }
|
|
260
283
|
url.pathname = `/v1/rooms/${row.current.room}`;
|
|
261
284
|
let ws: WebSocket;
|
|
262
|
-
|
|
285
|
+
// ponytail: negotiate only ephemeral here; replace this opt-in with #1204 T4 codec capabilities.
|
|
286
|
+
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
|
|
263
287
|
ws.binaryType = "arraybuffer";
|
|
264
288
|
const generation = ++this.generation;
|
|
265
|
-
const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
|
|
289
|
+
const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", pendingPresenceReplies: new Set(), presenceAsked: false, outbound: [], outboundBytes: 0, frameSent: false, retryAttempt: 0, queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
|
|
266
290
|
handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
|
|
267
291
|
session.handshake.unref?.();
|
|
268
292
|
this.session = session;
|
|
@@ -276,6 +300,59 @@ export class RoomLink {
|
|
|
276
300
|
if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
|
|
277
301
|
session.ws.send(bytes);
|
|
278
302
|
}
|
|
303
|
+
private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
|
|
304
|
+
this.current(session);
|
|
305
|
+
// Only an unsent observation may be replaced. Never reorder an attempted frame.
|
|
306
|
+
if (presence) {
|
|
307
|
+
const at = session.outbound.findIndex((frame, index) => frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
308
|
+
if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
|
|
309
|
+
}
|
|
310
|
+
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
|
|
311
|
+
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
|
|
312
|
+
this.pumpFrames(session);
|
|
313
|
+
}
|
|
314
|
+
private pumpFrames(session: Session): void {
|
|
315
|
+
if (session.frameSent || session.frameRetry || !session.outbound.length) return;
|
|
316
|
+
this.current(session);
|
|
317
|
+
const frame = session.outbound[0]!;
|
|
318
|
+
if (frame.retained && !frame.retained()) {
|
|
319
|
+
// Disposed/expired intent cannot survive in the transport's RAM queue.
|
|
320
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
|
|
321
|
+
this.pumpFrames(session); return;
|
|
322
|
+
}
|
|
323
|
+
if (!frame.current()) {
|
|
324
|
+
if (!frame.presence) fail("state_changed");
|
|
325
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.lastPresence = undefined;
|
|
326
|
+
this.pumpFrames(session);
|
|
327
|
+
void this.publishPresence().catch(() => this.retry(session));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// A foreground receipt follows the chat ACK on the socket. Otherwise broker
|
|
331
|
+
// verification/storage of the receipt can pin the chat's one-packet slot.
|
|
332
|
+
if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
|
|
333
|
+
this.send(session, frame.wire); session.frameSent = true;
|
|
334
|
+
const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
|
|
335
|
+
}
|
|
336
|
+
private storedFrame(session: Session, digest: string): void {
|
|
337
|
+
if (session.outbound[0]?.digest !== digest) return;
|
|
338
|
+
const frame = session.outbound.shift()!; session.outboundBytes -= frame.wire.byteLength;
|
|
339
|
+
session.frameSent = false; session.retryAttempt = 0;
|
|
340
|
+
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
341
|
+
session.frameRetry = undefined;
|
|
342
|
+
this.pumpFrames(session);
|
|
343
|
+
}
|
|
344
|
+
private backoffFrame(session: Session, digest: string): void {
|
|
345
|
+
if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
|
|
346
|
+
session.frameSent = false;
|
|
347
|
+
const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
|
|
348
|
+
session.frameRetry = setTimeout(() => {
|
|
349
|
+
session.frameRetry = undefined;
|
|
350
|
+
if (this.session !== session || !this.running || session.phase !== "ready") return;
|
|
351
|
+
try { this.pumpFrames(session); } catch { this.retry(session); }
|
|
352
|
+
}, delay);
|
|
353
|
+
session.frameRetry.unref?.();
|
|
354
|
+
this.pumpFrames(session);
|
|
355
|
+
}
|
|
279
356
|
private receive(session: Session, data: unknown): void {
|
|
280
357
|
if (this.session !== session || !this.running) return;
|
|
281
358
|
if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > LIMITS.packetBytes || session.queue.length + (session.deferredFrame ? 1 : 0) >= LIMITS.packets || session.queuedBytes + (session.deferredFrame?.byteLength ?? 0) + data.byteLength > LIMITS.queuedBytes) { this.retry(session); return; }
|
|
@@ -327,13 +404,19 @@ export class RoomLink {
|
|
|
327
404
|
return;
|
|
328
405
|
}
|
|
329
406
|
if (message.type === "ready") {
|
|
330
|
-
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest"]), roster = session.roster;
|
|
407
|
+
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
|
|
408
|
+
if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
|
|
331
409
|
if (!roster || ready.room !== local.current.room || ready.member !== this.options.store.identity.public.fingerprint || ready.epoch !== roster.epoch || ready.digest !== roster.digest) fail("untrusted");
|
|
332
410
|
if (roster.digest !== local.current.digest) {
|
|
333
411
|
if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
|
|
334
412
|
session.ownerUpgrading = true;
|
|
335
413
|
this.send(session, local.wire); return;
|
|
336
414
|
}
|
|
415
|
+
if (session.presenceRoster !== roster.digest) {
|
|
416
|
+
session.presenceRoster = roster.digest; session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
417
|
+
session.requestPresenceReply = true; session.lastPresence = undefined;
|
|
418
|
+
}
|
|
419
|
+
session.ephemeral = ready.ephemeral === true;
|
|
337
420
|
session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
|
|
338
421
|
if (session.deferredFrame) {
|
|
339
422
|
session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
|
|
@@ -341,6 +424,7 @@ export class RoomLink {
|
|
|
341
424
|
this.setState("online");
|
|
342
425
|
const context = this.context(session, local.current.digest);
|
|
343
426
|
this.options.application?.members(context);
|
|
427
|
+
this.restoreReceipts(session);
|
|
344
428
|
await this.retryPendingApplications();
|
|
345
429
|
this.current(session, local.current.digest);
|
|
346
430
|
await this.options.application?.online(context);
|
|
@@ -365,7 +449,14 @@ export class RoomLink {
|
|
|
365
449
|
if (message.type === "stored") {
|
|
366
450
|
const row = exact(message, ["v", "type", "digest", "seq", "duplicate"]);
|
|
367
451
|
if (typeof row.digest !== "string" || !DIGEST.test(row.digest) || typeof row.seq !== "string" || !/^[1-9][0-9]{0,19}$/.test(row.seq) || typeof row.duplicate !== "boolean") fail();
|
|
452
|
+
const receipt = this.options.store.receiptSendable(this.options.name, row.digest);
|
|
453
|
+
if (receipt) {
|
|
454
|
+
this.options.store.markStored(this.options.name, row.digest, row.seq);
|
|
455
|
+
this.options.store.retireStoredReceipt(this.options.name, row.digest);
|
|
456
|
+
}
|
|
457
|
+
this.storedFrame(session, row.digest);
|
|
368
458
|
this.options.application?.stored(this.context(session, local.current.digest), { digest: row.digest, seq: row.seq });
|
|
459
|
+
this.restoreReceipts(session);
|
|
369
460
|
return; // Broker storage is not a signed recipient application-completion receipt.
|
|
370
461
|
}
|
|
371
462
|
if (message.type === "queue_ack") {
|
|
@@ -381,10 +472,17 @@ export class RoomLink {
|
|
|
381
472
|
return;
|
|
382
473
|
}
|
|
383
474
|
if (message.type === "error") {
|
|
384
|
-
const error = exact(message, ["v", "type", "code"]);
|
|
475
|
+
const error = exact(message, Object.hasOwn(message, "digest") ? ["v", "type", "code", "digest"] : ["v", "type", "code"]);
|
|
476
|
+
if (error.digest !== undefined && (error.code !== "queue_full" || typeof error.digest !== "string" || !DIGEST.test(error.digest))) fail();
|
|
477
|
+
if (error.code === "queue_full") {
|
|
478
|
+
// Older brokers omit correlation; one frame in flight makes its head unambiguous.
|
|
479
|
+
const digest = typeof error.digest === "string" ? error.digest : session.outbound[0]?.digest;
|
|
480
|
+
if (digest) this.backoffFrame(session, digest);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
385
483
|
// X2 declares these refusal controls nonterminal. Their unsigned control
|
|
386
484
|
// cannot settle any application; its local ACK deadline records uncertainty.
|
|
387
|
-
if (error.code === "
|
|
485
|
+
if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
|
|
388
486
|
fail("unavailable");
|
|
389
487
|
}
|
|
390
488
|
fail();
|
|
@@ -449,6 +547,7 @@ export class RoomLink {
|
|
|
449
547
|
else throw error;
|
|
450
548
|
}
|
|
451
549
|
const acceptedDigest = await this.frameDigest(bytes);
|
|
550
|
+
session.processingFrame = acceptedDigest;
|
|
452
551
|
const latest = this.frameCurrent(session, frame);
|
|
453
552
|
if (latest.current.digest !== digest) refused = "trust_changed";
|
|
454
553
|
if (result?.status === "stale") {
|
|
@@ -471,6 +570,8 @@ export class RoomLink {
|
|
|
471
570
|
} else {
|
|
472
571
|
const peers = presence(result.plaintext, sender.exposedPeers);
|
|
473
572
|
this.current(session, digest);
|
|
573
|
+
session.pendingPresenceReplies.delete(frame.sender);
|
|
574
|
+
if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
|
|
474
575
|
try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
|
|
475
576
|
this.current(session, digest);
|
|
476
577
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
@@ -486,9 +587,32 @@ export class RoomLink {
|
|
|
486
587
|
this.frameCurrent(session, frame);
|
|
487
588
|
// This ACK only retires ciphertext from the broker queue. A durable refusal
|
|
488
589
|
// or stale drop is never a signed recipient application-completion receipt.
|
|
489
|
-
this.
|
|
590
|
+
this.retireFrame(session, frame, acceptedDigest);
|
|
591
|
+
session.processingFrame = undefined; session.acknowledgedFrame = undefined;
|
|
490
592
|
await this.refreshPresenceIfCapacityFree().catch(() => undefined);
|
|
491
593
|
}
|
|
594
|
+
private retireFrame(session: Session, frame: FrameWire, digest: string): void {
|
|
595
|
+
this.frameCurrent(session, frame);
|
|
596
|
+
// Final admission commits its receipt; deferred admission retains the durable inbox.
|
|
597
|
+
// Waiting for broker storage or deferred admission would pin capacity one indefinitely.
|
|
598
|
+
if (this.options.store.applicationReceipt(this.options.name, digest)
|
|
599
|
+
&& !this.options.store.receiptStored(this.options.name, digest)) fail("unavailable");
|
|
600
|
+
this.send(session, encodeBrokerControl({ v: 1, type: "received", digest }));
|
|
601
|
+
session.acknowledgedFrame = digest;
|
|
602
|
+
this.pumpFrames(session);
|
|
603
|
+
}
|
|
604
|
+
private restoreReceipts(session: Session): void {
|
|
605
|
+
this.current(session);
|
|
606
|
+
this.options.store.expireReceipts(this.options.name);
|
|
607
|
+
for (const entry of this.options.store.outbox(this.options.name)) {
|
|
608
|
+
if (entry.kind !== "receipt" || session.outbound.some(frame => frame.digest === entry.digest)) continue;
|
|
609
|
+
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + entry.wire.byteLength > LIMITS.queuedBytes) break;
|
|
610
|
+
const roster = this.current(session).current.digest;
|
|
611
|
+
this.queueFrame(session, entry.wire, entry.digest, false, () => this.publicationCurrent(session, roster), false,
|
|
612
|
+
() => this.options.store.receiptSendable(this.options.name, entry.digest), undefined,
|
|
613
|
+
entry.receiptFor === session.processingFrame ? entry.receiptFor : undefined);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
492
616
|
private async frameDigest(bytes: Uint8Array): Promise<string> {
|
|
493
617
|
return encodeBase64url(await sha256(bytes));
|
|
494
618
|
}
|
|
@@ -511,29 +635,86 @@ export class RoomLink {
|
|
|
511
635
|
async publishApplication(message: RoomMessage, recipient: string, outgoing?: RoomOutgoingInput, current: () => boolean = () => true): Promise<string> {
|
|
512
636
|
const plaintext = encodeRoomMessage(message), metadata = outgoing ? { ...outgoing } : undefined;
|
|
513
637
|
const session = this.session;
|
|
514
|
-
if (!session || session.phase !== "ready") fail("unavailable");
|
|
638
|
+
if (!session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
|
|
515
639
|
const digest = this.current(session).current.digest;
|
|
516
640
|
const allowed = () => this.publicationCurrent(session, digest) && current();
|
|
517
|
-
|
|
641
|
+
let queueStarted = false;
|
|
642
|
+
try {
|
|
643
|
+
let presenceWork: Promise<void> | undefined;
|
|
644
|
+
// A chat is the transport barrier for earlier observations. Receipts keep
|
|
645
|
+
// their durable-commit/ACK path and do not wait behind a new observation.
|
|
646
|
+
if (message.type === "chat" && this.republish) {
|
|
647
|
+
clearTimeout(this.republish); this.republish = undefined;
|
|
648
|
+
presenceWork = this.publishPresence();
|
|
649
|
+
// It may fail before reaching the serial lane. Observe rejection now;
|
|
650
|
+
// the reserved chat slot still awaits the original error below.
|
|
651
|
+
void presenceWork.catch(() => undefined);
|
|
652
|
+
}
|
|
653
|
+
// Reserve this chat's FIFO slot before yielding to the earlier observation.
|
|
654
|
+
return await this.serialSend(async () => {
|
|
655
|
+
if (presenceWork) await presenceWork;
|
|
518
656
|
if (!allowed()) fail("state_changed");
|
|
657
|
+
if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
|
|
658
|
+
|| session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
|
|
659
|
+
if (message.type === "receipt") {
|
|
660
|
+
const pending = this.options.store.receiptStored(this.options.name, message.frame);
|
|
661
|
+
if (pending?.state === "dispatching") {
|
|
662
|
+
if (!decodeFrame(pending.wire).recipients.includes(recipient)) fail("untrusted");
|
|
663
|
+
this.restoreReceipts(session);
|
|
664
|
+
return pending.digest;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
519
667
|
const row = this.current(session, digest);
|
|
520
668
|
if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
|
|
521
|
-
|
|
669
|
+
let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
|
|
522
670
|
if (!allowed()) fail("state_changed");
|
|
523
|
-
|
|
524
|
-
|
|
671
|
+
let frameDigest: string;
|
|
672
|
+
if (metadata) {
|
|
673
|
+
queueStarted = true;
|
|
674
|
+
let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
|
|
675
|
+
try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, allowed); }
|
|
676
|
+
catch (error) {
|
|
677
|
+
// An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
|
|
678
|
+
if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
|
|
679
|
+
&& !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
frameDigest = record.frameDigest ?? record.digest;
|
|
683
|
+
} else if (message.type === "receipt") {
|
|
684
|
+
const ttlMs = this.options.application?.receiptTtlMs?.(this.context(session, digest)) ?? RECEIPT_TTL_MS;
|
|
685
|
+
const receipt = await this.options.store.queueReceipt(this.options.name, message.frame, wire, { ttlMs, replaceExisting: true }, allowed);
|
|
686
|
+
if (!allowed()) fail("state_changed");
|
|
687
|
+
if (receipt.state === "accepted") return receipt.digest; // Existing broker-stored proof needs no pending wire slot.
|
|
688
|
+
frameDigest = receipt.digest; wire = receipt.wire;
|
|
689
|
+
} else frameDigest = await this.frameDigest(wire);
|
|
525
690
|
if (!allowed()) fail("state_changed");
|
|
526
|
-
|
|
691
|
+
try {
|
|
692
|
+
this.queueFrame(session, wire, frameDigest, false, allowed, false,
|
|
693
|
+
metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
|
|
694
|
+
: message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
|
|
695
|
+
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
// The durable outbox owns retry after local backpressure; this is no uncertainty.
|
|
698
|
+
if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
|
|
699
|
+
if (session.outbound[0]?.digest === frameDigest) this.backoffFrame(session, frameDigest);
|
|
700
|
+
}
|
|
527
701
|
return frameDigest;
|
|
528
|
-
});
|
|
702
|
+
}); } catch (error) {
|
|
703
|
+
if (metadata && !queueStarted) throw new RoomNotSentError();
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
529
706
|
}
|
|
530
707
|
async publishPresence(): Promise<void> {
|
|
531
708
|
const session = this.session;
|
|
532
709
|
if (!session || session.phase !== "ready") return;
|
|
533
710
|
if (this.publishing) { this.publishDirty = true; return; }
|
|
711
|
+
// Keep the already scheduled coalesced snapshot. Cancelling it on every
|
|
712
|
+
// state flip turns an awaited burst back into one durable seal per call.
|
|
713
|
+
if (this.republish && !this.replyPresence && !this.forcePresenceRequest) { this.publishDirty = true; return; }
|
|
534
714
|
if (this.republish) clearTimeout(this.republish);
|
|
535
715
|
this.republish = undefined; this.publishDirty = false;
|
|
536
|
-
const
|
|
716
|
+
const forceRequest = this.forcePresenceRequest;
|
|
717
|
+
const response = this.replyPresence && !forceRequest;
|
|
537
718
|
if (response) this.replyPresence = false;
|
|
538
719
|
this.forcePresenceRequest = false;
|
|
539
720
|
let sent = false;
|
|
@@ -549,17 +730,24 @@ export class RoomLink {
|
|
|
549
730
|
await this.serialSend(async () => {
|
|
550
731
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
551
732
|
const requestReply = !response && session.requestPresenceReply;
|
|
552
|
-
const
|
|
733
|
+
const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
|
|
734
|
+
if (!response && !forceRequest && session.lastPresence === presenceKey) return;
|
|
735
|
+
const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
|
|
553
736
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
554
737
|
// Machine-local exposure can change during reserve/encryption/signing even
|
|
555
738
|
// when the signed roster is unchanged. Never emit an id it now withholds.
|
|
739
|
+
const frameDigest = await this.frameDigest(wire);
|
|
740
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
556
741
|
const allowed = new Set(this.currentPresence(own.exposedPeers).map(peer => peer.id));
|
|
557
742
|
if (peers.some(peer => !allowed.has(peer.id))) return;
|
|
558
|
-
this.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
743
|
+
this.queueFrame(session, wire, frameDigest, requestReply,
|
|
744
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(peer => this.currentPresence(own.exposedPeers).some(value => value.id === peer.id)), true, undefined,
|
|
745
|
+
requestReply ? () => {
|
|
746
|
+
if (!session.presenceAsked) { session.pendingPresenceReplies = new Set(recipients); session.presenceAsked = true; }
|
|
747
|
+
session.requestPresenceReply = session.pendingPresenceReplies.size > 0;
|
|
748
|
+
if (this.retiredPresenceBytes && this.options.store.ordinaryInboxAvailable(this.options.name, this.retiredPresenceBytes)) this.retiredPresenceBytes = 0;
|
|
749
|
+
} : undefined);
|
|
750
|
+
session.lastPresence = presenceKey; sent = true;
|
|
563
751
|
});
|
|
564
752
|
} catch (error) {
|
|
565
753
|
this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
|