@staix/agent-hub 0.32.0 → 0.33.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/docs/design/bus.md +68 -2
  4. package/docs/dogfood.md +1 -0
  5. package/docs/memory.md +9 -3
  6. package/docs/operations.md +47 -14
  7. package/docs/quickstart.md +12 -2
  8. package/docs/security.md +28 -9
  9. package/docs/specs/2026-09-19-agent-hub-design.md +31 -4
  10. package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
  11. package/docs/verification/2026-11-06-0.33.0.md +222 -0
  12. package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
  13. package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
  14. package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
  15. package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
  16. package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
  17. package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
  18. package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
  19. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  20. package/docs/verified.json +10 -10
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +528 -206
  24. package/src/adapters/compat.ts +11 -11
  25. package/src/broker/protocol.ts +5 -0
  26. package/src/broker/server.ts +54 -4
  27. package/src/broker/store.ts +6 -3
  28. package/src/cli/output.ts +5 -1
  29. package/src/cli/room.ts +17 -5
  30. package/src/cli/status-lines.ts +8 -1
  31. package/src/cli/terminal-recovery.ts +37 -15
  32. package/src/hub/bus.ts +7 -0
  33. package/src/hub/daemon.ts +13 -4
  34. package/src/rooms/application.ts +15 -0
  35. package/src/rooms/commands.ts +76 -16
  36. package/src/rooms/health.ts +32 -0
  37. package/src/rooms/link.ts +267 -43
  38. package/src/rooms/remote-peer.ts +18 -3
  39. package/src/rooms/service.ts +27 -12
  40. package/src/rooms/status.ts +57 -1
  41. package/src/rooms/store.ts +20 -1
  42. package/src/rooms/wire.ts +10 -8
  43. package/src/ui/index.html +1 -1
@@ -3,7 +3,7 @@ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
3
3
  import type { Envelope, PeerState } from "../hub/envelope.ts";
4
4
  import { encodeRoomMessage, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
5
5
  import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
6
- import { RoomNotSentError } from "./link.ts";
6
+ import { RoomCodecPendingError, RoomCodecUnsupportedError, RoomNotSentError } from "./link.ts";
7
7
 
8
8
  export interface RemotePeerPorts {
9
9
  member: string;
@@ -32,10 +32,12 @@ export class RemotePeer extends BasePeer {
32
32
  private running = false;
33
33
  private online = false;
34
34
  private blockedNotSent = false;
35
+ private codecWaiting = false;
35
36
  private observed: PeerState = "offline";
36
37
  private readonly pending = new Set<string>();
37
38
  private readonly waitingForReady = new Set<string>();
38
39
  private readonly sending = new Set<string>();
40
+ private readonly codecRechecks = new Map<string, () => boolean>();
39
41
  private readonly deadlines = new Map<string, number>();
40
42
  constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
41
43
  super(id);
@@ -60,6 +62,7 @@ export class RemotePeer extends BasePeer {
60
62
  // Active journal rows and their sealed room frames survive a sender restart.
61
63
  // Stop only fences in-flight callbacks; it is not evidence of an uncertain send.
62
64
  this.pending.clear();
65
+ this.codecWaiting = false; this.codecRechecks.clear();
63
66
  this.waitingForReady.clear();
64
67
  this.deadlines.clear(); this.update();
65
68
  }
@@ -125,6 +128,7 @@ export class RemotePeer extends BasePeer {
125
128
  }
126
129
  /** A link becoming ready is an actual wake-up after a typed not-sent refusal. */
127
130
  wake(): void { this.blockedNotSent = false; this.update(); }
131
+ wakeCodec(): void { if (this.codecWaiting) { this.codecWaiting = false; this.wake(); } }
128
132
  setRemoteDeadline(envelopeId: string, expiresAt: number): void {
129
133
  if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
130
134
  }
@@ -159,7 +163,11 @@ export class RemotePeer extends BasePeer {
159
163
  if (this.sending.has(deliveryId)) return;
160
164
  this.sending.add(deliveryId);
161
165
  try { await this.publishOnce(env, deliveryId, expiresAt, generation, immutable); }
162
- finally { this.sending.delete(deliveryId); }
166
+ finally {
167
+ this.sending.delete(deliveryId);
168
+ const changed = this.codecRechecks.get(deliveryId); this.codecRechecks.delete(deliveryId);
169
+ if (changed?.()) { this.codecWaiting = false; this.wake(); } // Recheck only after this send lane released, closing observation-before-requeue races.
170
+ }
163
171
  }
164
172
  private async publishOnce(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
165
173
  let context: RoomBoundaryContext;
@@ -216,12 +224,19 @@ export class RemotePeer extends BasePeer {
216
224
  }
217
225
  } catch (error) {
218
226
  if (!this.pending.has(deliveryId)) return;
227
+ if (error instanceof RoomCodecUnsupportedError && !immutable) {
228
+ let queued: boolean | undefined;
229
+ try { queued = this.ports.queued?.(deliveryId); } catch { queued = undefined; }
230
+ if (queued === false) { this.receipt({ id: deliveryId, state: "failed_safe", reason: error.message }); return; }
231
+ }
232
+ if (error instanceof RoomCodecPendingError) this.codecWaiting = true;
219
233
  if (error instanceof RoomNotSentError) {
220
234
  let retained: boolean | undefined;
221
235
  try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
222
236
  if (immutable || retained === true) {
223
- if (!this.ready()) this.waitForReady(deliveryId);
237
+ if (!this.ready() || error instanceof RoomCodecPendingError) this.waitForReady(deliveryId);
224
238
  } else this.notSent(deliveryId);
239
+ if (error instanceof RoomCodecPendingError) this.codecRechecks.set(deliveryId, error.changed);
225
240
  return;
226
241
  }
227
242
  if (!trusted()) {
@@ -11,7 +11,7 @@ import { roomMembers, RoomWireError } from "./wire.ts";
11
11
  import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
12
12
  import { RoomPolicy } from "./policy.ts";
13
13
  import { RemotePeer } from "./remote-peer.ts";
14
- import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
14
+ import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope, type RoomCodecFeature } from "./application.ts";
15
15
  import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
16
16
  import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
17
17
 
@@ -32,7 +32,7 @@ export interface RoomChatOptions {
32
32
  roster?: (ids: readonly string[]) => void;
33
33
  changed?: () => void;
34
34
  limits?: () => LimitsConfig;
35
- notice?: (value: { code: "application_capacity"; room: string; count: number } | { code: "sequence_gap"; room: string; sender: string; count: string }) => void;
35
+ notice?: (value: { code: "application_capacity"; room: string; count: number }) => void;
36
36
  }
37
37
  export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
38
38
  interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
@@ -106,9 +106,9 @@ export class RoomChat {
106
106
  const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
107
107
  },
108
108
  capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
109
- gap: (context, input) => {
110
- if (context.current()) this.options.notice?.({ code: "sequence_gap", room: context.store.get(context.name)!.current.room, sender: input.sender, count: input.count });
111
- },
109
+ codecsChanged: (context, input) => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) { this.wakeRemotes(binding, input.member, true); void this.resumeActive(binding, input.member, input.features); } } },
110
+ // The signed sequence is room-wide, while chat recipients are selective.
111
+ // A numeric gap is not evidence of loss and must not become an operator notice.
112
112
  };
113
113
  }
114
114
  private now(): number { return this.options.now?.() ?? Date.now(); }
@@ -149,8 +149,10 @@ export class RoomChat {
149
149
  const target = this.targets.get(id);
150
150
  return !!target && target.binding.online && this.valid(target.binding);
151
151
  }
152
- private wakeRemotes(binding: Binding): void {
153
- for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding) remote.wake();
152
+ private wakeRemotes(binding: Binding, member?: string, codecOnly = false): void {
153
+ for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding && (member === undefined || this.targets.get(id)?.member === member)) {
154
+ if (codecOnly) remote.wakeCodec(); else remote.wake();
155
+ }
154
156
  }
155
157
  metrics(name: string, room?: string): RoomMetrics | null {
156
158
  const binding = this.bindings.get(name);
@@ -360,7 +362,7 @@ export class RoomChat {
360
362
  && this.policy.get(binding.name).includes(peer)
361
363
  && roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
362
364
  }
363
- presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }): void {
365
+ presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }): void {
364
366
  const binding = this.bindings.get(name), state = store.get(name);
365
367
  if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
366
368
  const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
@@ -371,7 +373,9 @@ export class RoomChat {
371
373
  const now = this.presenceNow();
372
374
  if (value.restored !== true) {
373
375
  binding.observedMembers.add(member.fingerprint);
374
- if (now !== undefined) binding.observedAt.set(member.fingerprint, now);
376
+ const receivedAt = Object.hasOwn(value, "observedAt") ? value.observedAt : now;
377
+ if (typeof receivedAt === "number" && Number.isSafeInteger(receivedAt) && receivedAt >= 0 && now !== undefined && receivedAt <= now) binding.observedAt.set(member.fingerprint, receivedAt);
378
+ else binding.observedAt.delete(member.fingerprint);
375
379
  }
376
380
  this.refreshTargets();
377
381
  this.queueChanged();
@@ -686,11 +690,18 @@ export class RoomChat {
686
690
  }
687
691
  void this.retryPending(); this.queueChanged();
688
692
  }
689
- private async resumeActive(binding: Binding): Promise<void> {
693
+ private async resumeActive(binding: Binding, codecMember?: string, changedFeatures?: readonly RoomCodecFeature[]): Promise<void> {
690
694
  if (!binding.online || !this.valid(binding)) return;
691
695
  try {
692
- const active = binding.store.applicationOutbox(binding.name, true).filter(row => (row.state === "dispatching" || row.state === "accepted")
693
- && !this.personHeldInvocations.get(binding.name)?.has(row.deliveryId));
696
+ const active = binding.store.applicationOutbox(binding.name, true).filter(row => {
697
+ if (row.state !== "dispatching" && row.state !== "accepted" || this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return false;
698
+ if (codecMember === undefined) return true;
699
+ if (row.recipient !== codecMember) return false;
700
+ let required: RoomCodecFeature | undefined;
701
+ if (row.peer === USER) required = "person_inbox_v1";
702
+ else if (this.options.bus.queueShow(row.deliveryId)?.originals.some(isInvocationKind)) required = "read_invocation_v1";
703
+ return required !== undefined && (changedFeatures === undefined || changedFeatures.includes(required));
704
+ });
694
705
  for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
695
706
  for (const row of active) this.launchResume(binding, row);
696
707
  } catch { /* The next authenticated online edge will retry durable rows. */ }
@@ -747,6 +758,10 @@ export class RoomChat {
747
758
  const digest = state?.current.digest;
748
759
  const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
749
760
  if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
761
+ let required: RoomCodecFeature | undefined;
762
+ if (isInvocationKind(original)) required = "read_invocation_v1";
763
+ else if (row.peer === USER) required = "person_inbox_v1";
764
+ if (required && !binding.link.codecSupported(row.recipient, required)) { remote.deferResume(row.deliveryId); return; }
750
765
  const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
751
766
  if (!ready()) { remote.waitForReady(row.deliveryId); return; }
752
767
  let readinessLost = false;
@@ -2,6 +2,7 @@ import { types } from "node:util";
2
2
  import type { PeerState } from "../hub/envelope.ts";
3
3
  import { decodeBase64url, encodeBase64url } from "./crypto.ts";
4
4
  import { parseRemotePeerId } from "./application.ts";
5
+ import { ROOM_HEALTH_CAUSES, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
5
6
 
6
7
  const ROOM_LIMIT = 64;
7
8
  const REMOTE_PEER_LIMIT = 4096;
@@ -15,6 +16,13 @@ const EPOCH = /^[1-9][0-9]{0,19}$/;
15
16
  const LINK_STATES = new Set(["online", "connecting", "offline", "left"]);
16
17
  const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
17
18
 
19
+ export interface RoomStatusHealth {
20
+ refused: { invalid_presence: number; wrong_epoch: number; trust_changed: number; stale: number; invalid_frame: number } | null;
21
+ outboxDepth: number | null;
22
+ oldestOutboxAgeMs: number | null;
23
+ lastReceiptAgeMs: number | null;
24
+ }
25
+
18
26
  export interface RoomStatusPeer {
19
27
  id: string;
20
28
  state: PeerState;
@@ -29,8 +37,15 @@ export interface RoomRelayStats {
29
37
  withheld: number | null;
30
38
  }
31
39
 
40
+ export interface RoomStatusDiagnostics {
41
+ health?: RoomStatusHealth | null;
42
+ linkCause?: RoomHealthCause | null;
43
+ linkNextStep?: string | null;
44
+ }
45
+ export interface RoomStatusUnavailable { code: RoomHealthCause | null; nextStep: string | null }
46
+
32
47
  /** Allowlisted room metadata. Missing member counts and peer lists stay unknown. */
33
- export interface RoomStatusView {
48
+ export interface RoomStatusView extends RoomStatusDiagnostics {
34
49
  name: string;
35
50
  room: string;
36
51
  owner: string;
@@ -123,6 +138,46 @@ function relayStats(value: unknown): RoomRelayStats | null {
123
138
  return { since, outbound, inbound, withheld };
124
139
  }
125
140
 
141
+ /** Counts and ages remain unknown when an older daemon cannot report them. */
142
+ export function projectRoomHealth(value: unknown): RoomStatusHealth | null {
143
+ if (!plainRecord(value)) return null;
144
+ const rawRefused = ownData(value, "refused");
145
+ let refused: RoomStatusHealth["refused"] = null;
146
+ if (plainRecord(rawRefused)) {
147
+ const invalid_presence = relayCount(ownData(rawRefused, "invalid_presence")), wrong_epoch = relayCount(ownData(rawRefused, "wrong_epoch"));
148
+ const trust_changed = relayCount(ownData(rawRefused, "trust_changed")), stale = relayCount(ownData(rawRefused, "stale")), invalid_frame = relayCount(ownData(rawRefused, "invalid_frame"));
149
+ if (invalid_presence !== null && wrong_epoch !== null && trust_changed !== null && stale !== null && invalid_frame !== null)
150
+ refused = { invalid_presence, wrong_epoch, trust_changed, stale, invalid_frame };
151
+ }
152
+ return { refused, outboxDepth: relayCount(ownData(value, "outboxDepth")),
153
+ oldestOutboxAgeMs: relayCount(ownData(value, "oldestOutboxAgeMs")), lastReceiptAgeMs: relayCount(ownData(value, "lastReceiptAgeMs")) };
154
+ }
155
+
156
+ function healthCause(value: unknown): RoomHealthCause | null {
157
+ return typeof value === "string" && ROOM_HEALTH_CAUSES.includes(value as RoomHealthCause) ? value as RoomHealthCause : null;
158
+ }
159
+
160
+ /** Regenerate guidance from closed causes; arbitrary daemon text is never copied. */
161
+ export function projectRoomDiagnostics(value: unknown): RoomStatusDiagnostics {
162
+ if (!plainRecord(value)) return {};
163
+ const cause = healthCause(ownData(value, "linkCause"));
164
+ const result: RoomStatusDiagnostics = {};
165
+ if (ownField(value, "health").present) result.health = projectRoomHealth(ownData(value, "health"));
166
+ if (ownField(value, "linkCause").present) {
167
+ result.linkCause = cause;
168
+ result.linkNextStep = null;
169
+ if (cause !== null) result.linkNextStep = roomHealthNextStep(cause);
170
+ }
171
+ return result;
172
+ }
173
+
174
+ /** Absent or malformed availability metadata stays unknown without evaluating getters. */
175
+ export function projectRoomUnavailable(value: unknown): RoomStatusUnavailable | null {
176
+ if (!plainRecord(value)) return null;
177
+ const code = healthCause(ownData(value, "code"));
178
+ return { code, nextStep: code === null ? null : roomHealthNextStep(code) };
179
+ }
180
+
126
181
  /** Project RoomCommands.rows-compatible data to body-free room status metadata. */
127
182
  export function projectRoomStatus(value: unknown): RoomStatusView[] {
128
183
  try {
@@ -146,6 +201,7 @@ export function projectRoomStatus(value: unknown): RoomStatusView[] {
146
201
  memberCount: memberCount(rawMemberCount),
147
202
  remotePeers: peers,
148
203
  relays: relayStats(ownData(item, "relays")),
204
+ ...projectRoomDiagnostics(item),
149
205
  });
150
206
  }
151
207
  return views;
@@ -2,6 +2,7 @@
2
2
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
+ import type { RoomHealth } from "./health.ts";
5
6
  import { realPath } from "../hub/project.ts";
6
7
  import { MAX_HOP, USER } from "../hub/envelope.ts";
7
8
  import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
@@ -1558,9 +1559,27 @@ export class RoomStore {
1558
1559
  });
1559
1560
  }
1560
1561
  refused(name: string): RoomRefusal[] {
1561
- const state = this.get(name); if (!state) return [];
1562
+ this.now(); named(name); const state = this.rooms.get(name); if (!state) return [];
1562
1563
  return this.db.query<RoomRefusal, [string]>("SELECT digest,reason,at FROM refusals WHERE room=? ORDER BY rowid").all(state.current.room);
1563
1564
  }
1565
+ /** Metadata-only diagnostics. No durable clock write, sealed frame or plaintext read. */
1566
+ health(name: string): RoomHealth {
1567
+ const now = this.now(); named(name); const state = this.rooms.get(name);
1568
+ if (!state) fail("untrusted");
1569
+ const refused: RoomHealth["refused"] = { invalid_presence: 0, wrong_epoch: 0, trust_changed: 0, stale: 0, invalid_frame: 0 };
1570
+ for (const row of this.db.query<{ reason: RoomRefusalReason; count: number }, [string]>("SELECT reason,COUNT(*) AS count FROM refusals WHERE room=? GROUP BY reason").all(state.current.room)) refused[row.reason] = row.count;
1571
+ const pending = this.db.query<{ depth: number; dated: number; oldest: number | null }, [string, number]>(`SELECT COUNT(*) AS depth, COUNT(COALESCE(r.created_at,a.created_at)) AS dated, MIN(COALESCE(r.created_at,a.created_at)) AS oldest
1572
+ FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest
1573
+ LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id
1574
+ LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest
1575
+ WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted'))
1576
+ AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?)`).get(state.current.room, now)!;
1577
+ const receipt = this.db.query<{ at: number | null }, [string]>(`SELECT MAX(i.received_at) AS at FROM inbox i
1578
+ JOIN application_outbox a ON a.room=i.room AND a.receipt_digest=i.digest WHERE i.room=?`).get(state.current.room)?.at;
1579
+ return { refused, outboxDepth: pending.depth,
1580
+ oldestOutboxAgeMs: pending.depth > 0 && pending.dated === pending.depth && pending.oldest !== null ? now - pending.oldest : null,
1581
+ lastReceiptAgeMs: receipt != null ? now - receipt : null };
1582
+ }
1564
1583
  /** Read-only physical-cap hint for a validated host payload; no body is loaded and no replay/clock row is written. */
1565
1584
  ordinaryInboxAvailable(name: string, bytes: number): boolean {
1566
1585
  this.now(); named(name);
package/src/rooms/wire.ts CHANGED
@@ -23,7 +23,7 @@ const FP = /^[0-9a-f]{64}$/;
23
23
  export interface RoomMember extends PublicHubIdentity { label: string; exposedPeers: string[] }
24
24
  export interface FrameWire {
25
25
  v: 1; type: "frame"; room: string; epoch: string; sender: string;
26
- recipients: string[]; seq: string; ephemeral?: true; nonce: string; ciphertext: string; signature: string;
26
+ recipients: string[]; seq: string; ephemeral?: true; codecFeatures?: true; nonce: string; ciphertext: string; signature: string;
27
27
  }
28
28
  export interface RosterWire {
29
29
  v: 1; type: "roster"; room: string; epoch: string; owner: string;
@@ -108,9 +108,10 @@ function wrap(x: unknown): WrappedEpochKey {
108
108
  }
109
109
  function parseFrame(x: unknown): FrameWire {
110
110
  const ephemeral = typeof x === "object" && x !== null && Object.hasOwn(x, "ephemeral");
111
- const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature", ...(ephemeral ? ["ephemeral"] : [])]); base(o, "frame");
112
- if (ephemeral && o.ephemeral !== true) fail();
113
- return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), ...(ephemeral ? { ephemeral: true as const } : {}), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
111
+ const codecFeatures = typeof x === "object" && x !== null && Object.hasOwn(x, "codecFeatures");
112
+ const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature", ...(ephemeral ? ["ephemeral"] : []), ...(codecFeatures ? ["codecFeatures"] : [])]); base(o, "frame");
113
+ if (ephemeral && o.ephemeral !== true || codecFeatures && (o.codecFeatures !== true || !ephemeral)) fail();
114
+ return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), ...(ephemeral ? { ephemeral: true as const } : {}), ...(codecFeatures ? { codecFeatures: true as const } : {}), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
114
115
  }
115
116
  function parseRoster(x: unknown): RosterWire {
116
117
  const o = object(x, ["v", "type", "room", "epoch", "owner", "members", "wraps", "privateNonce", "privateCiphertext", "signature"]); base(o, "roster");
@@ -231,6 +232,7 @@ export interface VerifiedPublicFrame {
231
232
  readonly rosterDigest: string;
232
233
  readonly recipients: readonly string[];
233
234
  readonly ephemeral?: true;
235
+ readonly codecFeatures?: true;
234
236
  }
235
237
  interface PublicFrameState { wire: Uint8Array }
236
238
  const publicFrames = new WeakMap<VerifiedPublicFrame, PublicFrameState>();
@@ -255,7 +257,7 @@ export async function verifyPublicFrame(input: { wire: Uint8Array; roster: Verif
255
257
  await verifyFrameSignature(frame, sender);
256
258
  const frameDigest = await digest(canonicalBytes(frame));
257
259
  const proof = Object.freeze({ room: frame.room, epoch: frame.epoch, sender: frame.sender, seq: frame.seq,
258
- digest: frameDigest, rosterDigest: roster.digest, ...(frame.ephemeral ? { ephemeral: true as const } : {}), recipients: Object.freeze([...frame.recipients]) });
260
+ digest: frameDigest, rosterDigest: roster.digest, ...(frame.ephemeral ? { ephemeral: true as const } : {}), ...(frame.codecFeatures ? { codecFeatures: true as const } : {}), recipients: Object.freeze([...frame.recipients]) });
259
261
  publicFrames.set(proof, { wire });
260
262
  return proof;
261
263
  }
@@ -333,10 +335,10 @@ function frameAad(frame: Pick<FrameWire, "room" | "epoch" | "sender" | "seq">):
333
335
  return enc.encode(`${frame.room}|${frame.epoch}|${frame.sender}|${frame.seq}`);
334
336
  }
335
337
  export async function sealFrame(input: {
336
- epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort; ephemeral?: true;
338
+ epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort; ephemeral?: true; codecFeatures?: true;
337
339
  }): Promise<Uint8Array> {
338
340
  await assertRoomCryptoReady();
339
- if (input.ephemeral !== undefined && input.ephemeral !== true) fail();
341
+ if (input.ephemeral !== undefined && input.ephemeral !== true || input.codecFeatures !== undefined && (input.codecFeatures !== true || input.ephemeral !== true)) fail();
340
342
  const epoch = input.epoch, identity = input.identity, sequence = input.sequence;
341
343
  const current = state(epoch), sender = identity.public.fingerprint;
342
344
  const member = current.roster.members.find(m => m.fingerprint === sender);
@@ -348,7 +350,7 @@ export async function sealFrame(input: {
348
350
  if (!sequence || typeof sequence.reserve !== "function" || typeof sequence.isCurrent !== "function") fail("state_unavailable");
349
351
  const binding = { room: epoch.room, sender, epoch: epoch.epoch, rosterDigest: epoch.digest };
350
352
  const seq = positiveU64(await portCall(() => sequence.reserve({ ...binding })));
351
- const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq, ...(input.ephemeral ? { ephemeral: true as const } : {}) };
353
+ const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq, ...(input.ephemeral ? { ephemeral: true as const } : {}), ...(input.codecFeatures ? { codecFeatures: true as const } : {}) };
352
354
  const nonce = randomBytes(12);
353
355
  const encrypted = await encryptEpoch({ key: current.key, purpose: "frame", context: frameContext(epoch, sender), aad: frameAad(header), nonce, plaintext });
354
356
  const body = { ...header, nonce: encodeBase64url(nonce), ciphertext: encodeBase64url(encrypted) };
package/src/ui/index.html CHANGED
@@ -2677,7 +2677,7 @@ function memorySyncLine(sync) {
2677
2677
  const cause = causes.includes(sync.cause) ? sync.cause : 'unknown';
2678
2678
  if (cause === 'declined' && sync.placementActive === false) {
2679
2679
  const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? String(sync.declinedCount) : 'unknown';
2680
- return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.';
2680
+ return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.';
2681
2681
  }
2682
2682
  const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? ' / ' + sync.declinedCount + ' declined' : '';
2683
2683
  const ids = cause === 'declined' && sync.declinedIdsKnown === false ? ' / declined ID set unknown' : '';