@staix/agent-hub 0.30.0 → 0.32.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 +52 -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 +11 -4
- package/docs/agent-notes/daemon.md +16 -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 +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -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 +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -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 +78 -17
- 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/room.ts +20 -10
- 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 +511 -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 +1576 -139
- 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/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- 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/commands.ts
CHANGED
|
@@ -3,14 +3,17 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { assertRoomCryptoReady, decodeBase64url, encodeBase64url, signBytes, verifyBytes } from "./crypto.ts";
|
|
5
5
|
import { acceptRoomEpoch, brokerUrl, canonicalBytes, createInvite, createJoin, createRoomEpoch, decodeInvite, decodeJoin, decodeRoster, roomMembers } from "./wire.ts";
|
|
6
|
-
import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem } from "./store.ts";
|
|
6
|
+
import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInboxItem, type RoomOutgoing } from "./store.ts";
|
|
7
7
|
import { RoomLink } from "./link.ts";
|
|
8
8
|
import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
|
|
9
9
|
import type { RoomChat } from "./service.ts";
|
|
10
10
|
import { projectRoomStatus, type RoomStatusView } from "./status.ts";
|
|
11
11
|
|
|
12
12
|
export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
|
|
13
|
-
export interface RoomPreview { owner: string; member: string; commit: () => Promise<RoomCommandResult> }
|
|
13
|
+
export interface RoomPreview { owner: string; member: string; outstanding?: readonly string[]; commit: () => Promise<RoomCommandResult> }
|
|
14
|
+
export class RoomRotationHoldError extends Error {
|
|
15
|
+
constructor() { super("room epoch rotated but delivery journal holds are unavailable; inspect ahub room status and ahub queue show before another mutation"); this.name = "RoomRotationHoldError"; }
|
|
16
|
+
}
|
|
14
17
|
const FP = /^[0-9a-f]{64}$/;
|
|
15
18
|
function refuse(code: string): never { throw new Error(`room: ${code}`); }
|
|
16
19
|
function fields(input: unknown, keys: string[], optional: string[] = []): Record<string, any> {
|
|
@@ -47,9 +50,10 @@ export class RoomCommands {
|
|
|
47
50
|
private pending?: Promise<RoomStore>;
|
|
48
51
|
private store?: RoomStore;
|
|
49
52
|
private stopped = false;
|
|
53
|
+
private stopWork?: Promise<void>;
|
|
50
54
|
private readonly links = new Map<string, RoomLink>();
|
|
51
55
|
private readonly policy: RoomPolicy;
|
|
52
|
-
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
56
|
+
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
53
57
|
private async open(): Promise<RoomStore> {
|
|
54
58
|
if (this.stopped) refuse("stopped");
|
|
55
59
|
this.pending ??= RoomStore.open({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
|
|
@@ -63,13 +67,19 @@ export class RoomCommands {
|
|
|
63
67
|
const store = await this.open();
|
|
64
68
|
if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
|
|
65
69
|
}
|
|
66
|
-
stop(): void {
|
|
70
|
+
stop(): Promise<void> {
|
|
71
|
+
if (this.stopWork) return this.stopWork;
|
|
72
|
+
this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
|
|
73
|
+
const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
|
|
74
|
+
this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
|
|
75
|
+
return this.stopWork;
|
|
76
|
+
}
|
|
67
77
|
private link(name: string): void {
|
|
68
78
|
if (this.stopped || !this.store || this.store.get(name)?.left) return;
|
|
69
79
|
let link = this.links.get(name);
|
|
70
80
|
if (!link) {
|
|
71
81
|
const store = this.store;
|
|
72
|
-
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application,
|
|
82
|
+
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
|
|
73
83
|
onPresence: value => this.options.chat?.presence(store, name, created, value),
|
|
74
84
|
onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
|
|
75
85
|
const allowed = new Set(this.policy.get(name));
|
|
@@ -78,7 +88,7 @@ export class RoomCommands {
|
|
|
78
88
|
this.links.set(name, created); link = created;
|
|
79
89
|
this.options.chat?.bind(store, name, created);
|
|
80
90
|
void created.start().catch(() => undefined);
|
|
81
|
-
} else { this.options.chat?.bind(this.store, name, link); link.refresh(); }
|
|
91
|
+
} else { this.options.chat?.bind(this.store, name, link); link.refresh(); void link.start().catch(() => undefined); }
|
|
82
92
|
}
|
|
83
93
|
private presenceTimer?: ReturnType<typeof setTimeout>;
|
|
84
94
|
changed(): void {
|
|
@@ -91,6 +101,26 @@ export class RoomCommands {
|
|
|
91
101
|
this.presenceTimer.unref?.();
|
|
92
102
|
}
|
|
93
103
|
private current(check: () => boolean): void { if (this.stopped || !check()) refuse("state_changed"); }
|
|
104
|
+
private async rotate(store: RoomStore, name: string, digest: string, expected: readonly RoomOutgoing[], check: () => boolean,
|
|
105
|
+
operation: (current: () => boolean) => Promise<RoomState>, after?: () => void): Promise<RoomState> {
|
|
106
|
+
const ids = expected.map(row => row.deliveryId).sort(), release = await this.links.get(name)?.quiesceApplications();
|
|
107
|
+
let next: RoomState | undefined, returned = false;
|
|
108
|
+
const current = () => !this.stopped && check() && store.get(name)?.current.digest === digest
|
|
109
|
+
&& JSON.stringify(store.rotationPending(name).map(row => row.deliveryId).sort()) === JSON.stringify(ids);
|
|
110
|
+
try {
|
|
111
|
+
if (!current()) refuse("state_changed");
|
|
112
|
+
next = await operation(current);
|
|
113
|
+
const held = next.current.digest === digest || this.options.chat?.epochRotated(store, name, digest, expected) !== false;
|
|
114
|
+
this.current(check); after?.();
|
|
115
|
+
if (!held) throw new RoomRotationHoldError();
|
|
116
|
+
returned = true; return next;
|
|
117
|
+
} finally {
|
|
118
|
+
release?.();
|
|
119
|
+
// A committed epoch still needs publication if its journal label or local
|
|
120
|
+
// policy update failed; its old sealed intents were already fenced atomically.
|
|
121
|
+
if (next && !returned && next.current.digest !== digest) this.link(name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
94
124
|
private active(store: RoomStore, name: string): RoomState {
|
|
95
125
|
const state = store.get(name); if (!state || state.left) refuse("unavailable"); return state;
|
|
96
126
|
}
|
|
@@ -196,9 +226,12 @@ export class RoomCommands {
|
|
|
196
226
|
? { room: existing.current.room, owner, minEpoch: existing.current.epoch, digest: existing.current.digest }
|
|
197
227
|
: { room: invitation!.room, owner, minEpoch: "0" } });
|
|
198
228
|
this.current(check);
|
|
199
|
-
|
|
229
|
+
const outstanding = existing ? store.rotationPending(name) : [];
|
|
230
|
+
return { owner, member: own, ...(existing ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
|
|
200
231
|
this.current(check);
|
|
201
|
-
const state = await
|
|
232
|
+
const state = existing ? await this.rotate(store, name, existing.current.digest, outstanding, check, current =>
|
|
233
|
+
store.install({ name, broker, wire, expectedDigest: existing.current.digest, current, reactivate: true }))
|
|
234
|
+
: await store.install({ name, broker, wire, expectedDigest: null, current: check, reactivate: true });
|
|
202
235
|
return completed(state);
|
|
203
236
|
} };
|
|
204
237
|
}
|
|
@@ -235,7 +268,11 @@ export class RoomCommands {
|
|
|
235
268
|
const state = this.active(store, name), owner = this.owner(state), digest = state.current.digest;
|
|
236
269
|
if (action === "leave") return { owner, member: own, commit: async () => {
|
|
237
270
|
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
238
|
-
|
|
271
|
+
await this.links.get(name)?.stop();
|
|
272
|
+
try {
|
|
273
|
+
this.current(check); if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
274
|
+
store.leave(name); this.links.delete(name); return { name, room: state.current.room, owner, member: own };
|
|
275
|
+
} catch (error) { this.link(name); throw error; }
|
|
239
276
|
} };
|
|
240
277
|
if (action === "invite") {
|
|
241
278
|
if (owner !== own) refuse("owner_only");
|
|
@@ -254,9 +291,12 @@ export class RoomCommands {
|
|
|
254
291
|
if (!sender || exposure.room !== state.current.room || exposure.owner !== owner || exposure.digest !== digest || !await verifyBytes({ fingerprint: sender.fingerprint, signingKey: sender.signingKey, agreementKey: sender.agreementKey }, exposureBytes(body), decodeBase64url(signature, 64))) refuse("untrusted");
|
|
255
292
|
}
|
|
256
293
|
this.current(check);
|
|
257
|
-
|
|
294
|
+
const outstanding = store.rotationPending(name);
|
|
295
|
+
return { owner, member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
|
|
258
296
|
this.current(check);
|
|
259
|
-
const next =
|
|
297
|
+
const next = await this.rotate(store, name, digest, outstanding, check, current => exposure
|
|
298
|
+
? store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === member ? { ...m, exposedPeers: exposedPeers(exposure.peers) } : m), digest, current)
|
|
299
|
+
: store.enroll(name, request, current));
|
|
260
300
|
this.current(check); completed(next);
|
|
261
301
|
return { name, owner, member, kind: "roster", artifact: encodeBase64url(next.wire) };
|
|
262
302
|
} };
|
|
@@ -264,19 +304,24 @@ export class RoomCommands {
|
|
|
264
304
|
if (action === "revoke") {
|
|
265
305
|
if (owner !== own) refuse("owner_only");
|
|
266
306
|
if (typeof row.member !== "string" || !FP.test(row.member) || row.member === own || !roomMembers(state.current).some(m => m.fingerprint === row.member)) refuse("invalid_member");
|
|
267
|
-
|
|
268
|
-
|
|
307
|
+
const outstanding = store.rotationPending(name);
|
|
308
|
+
return { owner, member: row.member, outstanding: outstanding.map(row => row.deliveryId).sort(), commit: async () => {
|
|
309
|
+
this.current(check); const next = await this.rotate(store, name, digest, outstanding, check, current =>
|
|
310
|
+
store.rotate(name, roomMembers(state.current).filter(m => m.fingerprint !== row.member), digest, current)); return completed(next);
|
|
269
311
|
} };
|
|
270
312
|
}
|
|
271
313
|
if (action === "expose") {
|
|
272
314
|
const peers = exposedPeers(row.peers ?? []);
|
|
273
315
|
this.policy.assertWritable(name, peers, check);
|
|
274
|
-
|
|
316
|
+
const outstanding = owner === own ? store.rotationPending(name) : [];
|
|
317
|
+
return { owner, member: own, ...(owner === own ? { outstanding: outstanding.map(row => row.deliveryId).sort() } : {}), commit: async () => {
|
|
275
318
|
this.current(check); this.policy.assertWritable(name, peers, check);
|
|
276
319
|
if (store.get(name)?.current.digest !== digest) refuse("state_changed");
|
|
277
320
|
if (owner === own) {
|
|
278
|
-
const next = await
|
|
279
|
-
|
|
321
|
+
const next = await this.rotate(store, name, digest, outstanding, check, current =>
|
|
322
|
+
store.rotate(name, roomMembers(state.current).map(m => m.fingerprint === own ? { ...m, exposedPeers: peers } : m), digest, current),
|
|
323
|
+
() => this.policy.set(name, peers, check));
|
|
324
|
+
return completed(next);
|
|
280
325
|
}
|
|
281
326
|
const body: Omit<ExposureRequest, "signature"> = { v: 1, type: "exposure", room: state.current.room, owner, member: own, digest, peers };
|
|
282
327
|
const request = canonicalBytes({ ...body, signature: encodeBase64url(await signBytes(store.identity, exposureBytes(body))) });
|