@securecoms/agent-bridge 0.2.0 → 0.3.1

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/dist/index.js CHANGED
@@ -430,6 +430,144 @@ var payloadsSchema = {
430
430
  "type": "object"
431
431
  }
432
432
  }
433
+ },
434
+ "agent_action": {
435
+ "description": 'The agent_action E2E message body (agent action tree). NOT a message_type \u2014 it rides envelope message_type "system_event"; this "type":"agent_action" field is a body-level discriminator. Two phases: partial (throttled, incremental upsert-by-id while an agent works) and settled (one authoritative full-tree frame, terminal for its runId). Note: this def is compiled standalone (see validate.ts validateAgentActionPayload), so its node sub-schema is nested under a LOCAL $defs (resolved relative to this def, not the sibling top-level $defs) rather than cross-referenced.',
436
+ "$defs": {
437
+ "node": {
438
+ "type": "object",
439
+ "required": [
440
+ "id",
441
+ "type",
442
+ "title",
443
+ "status",
444
+ "ts"
445
+ ],
446
+ "properties": {
447
+ "id": {
448
+ "type": "string"
449
+ },
450
+ "parentId": {
451
+ "type": "string"
452
+ },
453
+ "type": {
454
+ "type": "string",
455
+ "enum": [
456
+ "step",
457
+ "tool_call",
458
+ "sub_task",
459
+ "result"
460
+ ]
461
+ },
462
+ "title": {
463
+ "type": "string"
464
+ },
465
+ "status": {
466
+ "type": "string",
467
+ "enum": [
468
+ "pending",
469
+ "running",
470
+ "done",
471
+ "error"
472
+ ]
473
+ },
474
+ "detail": {
475
+ "type": "object",
476
+ "properties": {
477
+ "args": {},
478
+ "output": {},
479
+ "error": {
480
+ "type": "string"
481
+ }
482
+ }
483
+ },
484
+ "ts": {
485
+ "type": "number"
486
+ }
487
+ }
488
+ }
489
+ },
490
+ "oneOf": [
491
+ {
492
+ "type": "object",
493
+ "required": [
494
+ "v",
495
+ "type",
496
+ "phase",
497
+ "agentActionVersion",
498
+ "runId",
499
+ "nodes"
500
+ ],
501
+ "properties": {
502
+ "v": {
503
+ "const": 1
504
+ },
505
+ "type": {
506
+ "const": "agent_action"
507
+ },
508
+ "phase": {
509
+ "const": "partial"
510
+ },
511
+ "agentActionVersion": {
512
+ "const": "1"
513
+ },
514
+ "runId": {
515
+ "type": "string"
516
+ },
517
+ "nodes": {
518
+ "type": "array",
519
+ "items": {
520
+ "$ref": "#/$defs/node"
521
+ }
522
+ }
523
+ }
524
+ },
525
+ {
526
+ "type": "object",
527
+ "required": [
528
+ "v",
529
+ "type",
530
+ "phase",
531
+ "agentActionVersion",
532
+ "runId",
533
+ "startedAt",
534
+ "endedAt",
535
+ "nodes"
536
+ ],
537
+ "properties": {
538
+ "v": {
539
+ "const": 1
540
+ },
541
+ "type": {
542
+ "const": "agent_action"
543
+ },
544
+ "phase": {
545
+ "const": "settled"
546
+ },
547
+ "agentActionVersion": {
548
+ "const": "1"
549
+ },
550
+ "runId": {
551
+ "type": "string"
552
+ },
553
+ "startedAt": {
554
+ "type": "number"
555
+ },
556
+ "endedAt": {
557
+ "type": "number"
558
+ },
559
+ "resultMessageId": {
560
+ "type": "string"
561
+ },
562
+ "nodes": {
563
+ "type": "array",
564
+ "items": {
565
+ "$ref": "#/$defs/node"
566
+ }
567
+ }
568
+ }
569
+ }
570
+ ]
433
571
  }
434
572
  }
435
573
  };
@@ -449,6 +587,11 @@ for (const t of MESSAGE_TYPES) {
449
587
  }
450
588
  payloadValidators.set(t, ajv.compile(def));
451
589
  }
590
+ var agentActionDef = payloadDefs["agent_action"];
591
+ if (!agentActionDef) {
592
+ throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
593
+ }
594
+ var agentActionValidator = ajv.compile(agentActionDef);
452
595
 
453
596
  // ../connect-core/dist/envelope/sign.js
454
597
  import { canonicalize } from "@agentvault/crypto/canonical";
@@ -781,6 +924,24 @@ async function staleEpochErrorFromResponse(res) {
781
924
  return null;
782
925
  return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
783
926
  }
927
+ var AgentPausedError = class extends ConnectSdkError {
928
+ constructor() {
929
+ super("agent_paused: sending is paused for this agent in this room");
930
+ }
931
+ };
932
+ async function agentPausedErrorFromResponse(res) {
933
+ if (res.status !== 403)
934
+ return null;
935
+ let body;
936
+ try {
937
+ body = await res.json();
938
+ } catch {
939
+ return null;
940
+ }
941
+ const detail = body?.detail;
942
+ const err = detail?.error;
943
+ return err === "agent_paused" ? new AgentPausedError() : null;
944
+ }
784
945
 
785
946
  // ../connect-sdk-ts/dist/envelope-builder.js
786
947
  async function buildOutboundEnvelope(p) {
@@ -885,7 +1046,7 @@ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
885
1046
  var KNOWN = new Set(Object.values(SPAN));
886
1047
 
887
1048
  // ../connect-sdk-ts/dist/groupkey/mls.js
888
- import { MLSGroupManager, bytesToHex, hexToBytes } from "@agentvault/crypto";
1049
+ import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
889
1050
 
890
1051
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
891
1052
  var MlsOperationQueue = class {
@@ -914,11 +1075,27 @@ var MlsOperationQueue = class {
914
1075
  chainSize(channelId) {
915
1076
  return this.inFlight.get(channelId) ?? 0;
916
1077
  }
1078
+ /** Number of channels with an in-flight or queued op. 0 == fully idle. */
1079
+ get activeChannels() {
1080
+ return this.chains.size;
1081
+ }
1082
+ /**
1083
+ * H1 graceful-drain barrier: resolve once every op currently chained (in-flight
1084
+ * + already queued) has SETTLED. Ops enqueued AFTER this call are not awaited —
1085
+ * the caller must stop accepting new work first (e.g. close the ping stream).
1086
+ * Uses allSettled so a rejected op can't make the drain reject. Lets a graceful
1087
+ * shutdown wait for an in-flight commit/join/ack to finish before the DB handle
1088
+ * is released, so a SIGTERM/`kickstart -k` can't tear MLS state mid-operation.
1089
+ */
1090
+ async idle() {
1091
+ await Promise.allSettled([...this.chains.values()]);
1092
+ }
917
1093
  };
918
1094
 
919
1095
  // ../connect-sdk-ts/dist/groupkey/mls.js
920
1096
  var LABEL = "agentvault-connect-v1";
921
1097
  var DEFAULT_STUCK_THRESHOLD = 2;
1098
+ var DEFAULT_QUARANTINE_THRESHOLD = 8;
922
1099
  var BootstrapRaceLostError = class extends Error {
923
1100
  constructor() {
924
1101
  super("bootstrap race lost: another device created the group; join instead");
@@ -938,10 +1115,24 @@ var HealAddAfterRemoveError = class extends Error {
938
1115
  this.cause = cause;
939
1116
  }
940
1117
  };
1118
+ var GuestHealStaleKpError = class extends Error {
1119
+ constructor(kpId) {
1120
+ super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
1121
+ this.name = "GuestHealStaleKpError";
1122
+ }
1123
+ };
1124
+ var ForgedCredentialError = class extends Error {
1125
+ constructor(expected, actual) {
1126
+ super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
1127
+ this.name = "ForgedCredentialError";
1128
+ }
1129
+ };
941
1130
  var MlsGroupKeyProvider = class {
942
1131
  o;
943
1132
  suite;
944
1133
  managers = /* @__PURE__ */ new Map();
1134
+ /** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
1135
+ cryptoWarmed = false;
945
1136
  /**
946
1137
  * Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
947
1138
  * manager-mutating operation on a channel takes this lock keyed by channelId,
@@ -962,6 +1153,12 @@ var MlsGroupKeyProvider = class {
962
1153
  * holds that channel's lock).
963
1154
  */
964
1155
  locks = new MlsOperationQueue();
1156
+ /** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
1157
+ * guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
1158
+ * both pass "no pending bundle", both POST, and orphan the first bundle when the
1159
+ * server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
1160
+ * from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
1161
+ publishLock = new MlsOperationQueue();
965
1162
  /**
966
1163
  * Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
967
1164
  * of channels this device touches (channels are bounded; an entry is created
@@ -971,14 +1168,22 @@ var MlsGroupKeyProvider = class {
971
1168
  */
972
1169
  errors = /* @__PURE__ */ new Map();
973
1170
  stuckThreshold;
1171
+ /** Consecutive-error threshold at which a wedged channel's row is quarantined
1172
+ * (ack-drained). See {@link MlsGroupKeyProviderOpts.quarantineThreshold}. */
1173
+ quarantineThreshold;
974
1174
  /** Cumulative count of past-epoch application rows permanently skipped (acked
975
1175
  * and drained) because forward-secrecy has deleted their epoch's secrets. Read
976
1176
  * via {@link permanentlySkippedCount}. */
977
1177
  permanentlySkipped = 0;
1178
+ /** Cumulative count of rows ack-drained via QUARANTINE (P0a), as distinct from
1179
+ * the past-epoch {@link permanentlySkipped} drain. Read via
1180
+ * {@link quarantineDrainedCount}. */
1181
+ quarantineDrained = 0;
978
1182
  constructor(o) {
979
1183
  this.o = o;
980
1184
  this.suite = o.suite ?? "xchacha20poly1305";
981
1185
  this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
1186
+ this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
982
1187
  }
983
1188
  get deviceId() {
984
1189
  return this.o.deviceId;
@@ -996,7 +1201,14 @@ var MlsGroupKeyProvider = class {
996
1201
  const cid = row.connect_channel_id;
997
1202
  if (!cid)
998
1203
  return;
999
- const e = this.errors.get(cid) ?? { decryptErrors: 0, consecutiveErrors: 0, lastErrorAt: null };
1204
+ const e = this.errors.get(cid) ?? {
1205
+ decryptErrors: 0,
1206
+ consecutiveErrors: 0,
1207
+ lastErrorAt: null,
1208
+ quarantined: false,
1209
+ wedgedRun: 0,
1210
+ wedgedSignalled: false
1211
+ };
1000
1212
  e.decryptErrors += 1;
1001
1213
  e.consecutiveErrors += 1;
1002
1214
  e.lastErrorAt = Date.now();
@@ -1006,12 +1218,42 @@ var MlsGroupKeyProvider = class {
1006
1218
  * A commit/application row for the channel decrypted cleanly: the channel is
1007
1219
  * advancing, so clear the consecutive-error run (un-sticks it). Leaves the
1008
1220
  * cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
1009
- * that never errored.
1221
+ * that never errored. Also clears QUARANTINE state (P0a): a healed/rejoined
1222
+ * channel resumes normal processing and can quarantine again later if it
1223
+ * re-wedges (the onWedged guard is reset so the hook can fire again).
1010
1224
  */
1011
1225
  recordDecryptSuccess(channelId) {
1012
1226
  const e = this.errors.get(channelId);
1013
- if (e && e.consecutiveErrors !== 0)
1227
+ if (!e)
1228
+ return;
1229
+ if (e.consecutiveErrors !== 0)
1014
1230
  e.consecutiveErrors = 0;
1231
+ e.wedgedRun = 0;
1232
+ e.quarantined = false;
1233
+ e.wedgedSignalled = false;
1234
+ }
1235
+ /**
1236
+ * Called from the decrypt-failure catch in {@link receiveOnce} when the
1237
+ * channel's consecutive-error run has just reached {@link quarantineThreshold}
1238
+ * and the row was NOT already drained via the past-epoch `permanentlyDead`
1239
+ * check (P0a, daemon-stability). Marks the channel quarantined and fires
1240
+ * {@link MlsGroupKeyProviderOpts.onWedged} exactly once per episode. Does NOT
1241
+ * itself ack the row — the caller pushes `row.queue_id` onto the same acked
1242
+ * list the `permanentlyDead` drain uses, flushed via `ackDelivery` at the end
1243
+ * of `receiveOnce`. Deliberately independent of `liveEpoch`: it must work even
1244
+ * when there is no local state for the channel at all (the no-local-state /
1245
+ * Hermes storm case), because quarantine is a pure consecutive-failure-count
1246
+ * decision, not an epoch comparison.
1247
+ */
1248
+ quarantineChannel(channelId) {
1249
+ const e = this.errors.get(channelId);
1250
+ if (!e)
1251
+ return;
1252
+ e.quarantined = true;
1253
+ if (!e.wedgedSignalled) {
1254
+ e.wedgedSignalled = true;
1255
+ this.o.onWedged?.(channelId);
1256
+ }
1015
1257
  }
1016
1258
  /**
1017
1259
  * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
@@ -1028,11 +1270,42 @@ var MlsGroupKeyProvider = class {
1028
1270
  decryptErrors: e.decryptErrors,
1029
1271
  consecutiveErrors: e.consecutiveErrors,
1030
1272
  lastErrorAt: e.lastErrorAt,
1031
- stuck: e.consecutiveErrors >= this.stuckThreshold
1273
+ stuck: e.consecutiveErrors >= this.stuckThreshold,
1274
+ quarantined: e.quarantined
1032
1275
  };
1033
1276
  }
1034
1277
  return out;
1035
1278
  }
1279
+ /**
1280
+ * H1 graceful shutdown: resolve once every in-flight per-channel op AND the
1281
+ * publish lock have settled, or `deadlineMs` elapses (bounded so a hung network
1282
+ * op can't block process exit). The caller MUST have already stopped feeding new
1283
+ * work (closed the ping stream) so the queues can actually drain. Loops because a
1284
+ * settling op can chain a follow-on (e.g. a commit that then drains a pending
1285
+ * commit). Lets close() wait for an in-flight commit/join/ack to finish before
1286
+ * the DB handle is released — a SIGTERM / `kickstart -k` can no longer tear MLS
1287
+ * state mid-operation (a half-applied commit, or a join whose Welcome was acked
1288
+ * but whose state was not yet saved).
1289
+ */
1290
+ async drainInFlight(deadlineMs = 5e3) {
1291
+ let timer;
1292
+ const deadline = new Promise((res) => {
1293
+ timer = setTimeout(res, deadlineMs);
1294
+ timer.unref?.();
1295
+ });
1296
+ const drain = (async () => {
1297
+ while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
1298
+ await this.locks.idle();
1299
+ await this.publishLock.idle();
1300
+ }
1301
+ })();
1302
+ try {
1303
+ await Promise.race([drain, deadline]);
1304
+ } finally {
1305
+ if (timer)
1306
+ clearTimeout(timer);
1307
+ }
1308
+ }
1036
1309
  /** Total past-epoch rows permanently skipped (acked without decrypting) since
1037
1310
  * this provider was instantiated. A permanently-undecryptable row is one whose
1038
1311
  * `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
@@ -1040,6 +1313,12 @@ var MlsGroupKeyProvider = class {
1040
1313
  permanentlySkippedCount() {
1041
1314
  return this.permanentlySkipped;
1042
1315
  }
1316
+ /** Total rows ack-drained via QUARANTINE (P0a) since this provider was
1317
+ * instantiated — a wedged channel's undecryptable backlog, distinct from the
1318
+ * past-epoch {@link permanentlySkippedCount} drain. */
1319
+ quarantineDrainedCount() {
1320
+ return this.quarantineDrained;
1321
+ }
1043
1322
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1044
1323
  * device leaves or deletes a channel: dropping the persisted store record is
1045
1324
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1208,16 +1487,99 @@ var MlsGroupKeyProvider = class {
1208
1487
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
1209
1488
  return w?.connect_channel_id ?? null;
1210
1489
  }
1490
+ /**
1491
+ * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1492
+ * operation that deserializes a persisted KeyPackage. generateKeyPackage
1493
+ * awaits sodium.ready and is pure in-memory (it neither publishes to the
1494
+ * backend nor writes the store), so the throwaway bundle is a side-effect-free
1495
+ * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
1496
+ * guard, and the join path) runs while WASM is still cold, throws deep in the
1497
+ * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1498
+ * and misread as "no held bundle", and the daemon mints + republishes a FRESH
1499
+ * KeyPackage on every restart — orphaning every Welcome sealed to the prior
1500
+ * published KP (the root cause of "fresh DM never opens" across restarts).
1501
+ * Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
1502
+ */
1503
+ async ensureCryptoReady() {
1504
+ if (this.cryptoWarmed)
1505
+ return;
1506
+ await new MLSGroupManager().generateKeyPackage(this.o.identity);
1507
+ this.cryptoWarmed = true;
1508
+ }
1211
1509
  async publishKeyPackage() {
1510
+ await this.ensureCryptoReady();
1511
+ return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
1512
+ }
1513
+ /**
1514
+ * Force a FRESH published KeyPackage even when a pending bundle is already held.
1515
+ *
1516
+ * `publishKeyPackage`'s republish guard deliberately no-ops while a bundle is
1517
+ * held (so a normal re-publish can't orphan a leaf a queued Welcome needs). But
1518
+ * the reset self-heal reaches here only AFTER a fully-verified supersede Welcome
1519
+ * FAILED to open ("No matching secret found") — proving the currently-published
1520
+ * KP has diverged from our held bundle, so that bundle is already useless for
1521
+ * opening any Welcome sealed to the published KP. Delete it (dropping the guard)
1522
+ * and publish anew under the same device publish-lock, so the server's published
1523
+ * KP now matches a bundle we hold and the owner's re-Welcome is openable. Returns
1524
+ * the new KP id. Only the reset-unopenable self-heal calls this.
1525
+ */
1526
+ async forceRepublishKeyPackage() {
1527
+ return this.publishLock.run(this.o.deviceId, async () => {
1528
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1529
+ return this.publishKeyPackageUnlocked();
1530
+ });
1531
+ }
1532
+ /**
1533
+ * True iff we hold a pending bundle AND the server's currently-advertised
1534
+ * KeyPackage for this device is that SAME bundle's public KeyPackage.
1535
+ *
1536
+ * The reset self-heal uses this to avoid a needless — and harmful —
1537
+ * force-republish. A failed `joinFromWelcome` on a supersede proves only that
1538
+ * THIS Welcome's sealed KP != our held bundle; it does NOT prove the CURRENTLY
1539
+ * published KP diverged. In the common case the reset committer simply sealed to
1540
+ * an OLDER KP snapshot while the current published KP still equals our held
1541
+ * bundle — a plain re-Welcome (re-sealed to the current published KP) then opens,
1542
+ * and deleting the shared per-device bundle would ORPHAN other channels' openable
1543
+ * Welcomes sealed to it (the multichannel-join case). Only when this returns
1544
+ * false (published KP genuinely diverged from held) is a force-republish warranted:
1545
+ * in the normal single-daemon case every currently-sealed Welcome uses the published
1546
+ * KP, so deleting the held bundle orphans nothing; a Welcome sealed to the held
1547
+ * bundle can only exist in a rare multi-instance / out-of-band-republish window, and
1548
+ * even then a supersede Welcome self-heals next cycle (held == published again).
1549
+ *
1550
+ * Best-effort: any error resolving either side returns false, so the caller
1551
+ * force-republishes (the conservative recovery). */
1552
+ async heldBundleMatchesPublishedKp() {
1553
+ try {
1554
+ const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1555
+ if (!bundle)
1556
+ return false;
1557
+ const heldHex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1558
+ const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1559
+ const pubHex = published[this.o.deviceId];
1560
+ return !!pubHex && pubHex === heldHex;
1561
+ } catch {
1562
+ return false;
1563
+ }
1564
+ }
1565
+ async publishKeyPackageUnlocked() {
1212
1566
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1213
1567
  console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1214
- return;
1568
+ return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
1215
1569
  }
1570
+ console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1216
1571
  const mgr = new MLSGroupManager();
1217
1572
  const bundle = await mgr.generateKeyPackage(this.o.identity);
1218
1573
  const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1219
- await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1220
- await this.o.store.savePendingBundle(this.o.deviceId, bundle);
1574
+ const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1575
+ await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1576
+ return id;
1577
+ }
1578
+ /** The server-assigned id of the last KeyPackage this device published,
1579
+ * or null if none has been published yet. Lets a diverged guest's self-heal
1580
+ * signal name its outstanding KP (P2-A). */
1581
+ async getLastPublishedKpId() {
1582
+ return this.o.store.loadLastKpId(this.o.deviceId);
1221
1583
  }
1222
1584
  async ensureSession(channelId, opts) {
1223
1585
  return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
@@ -1225,6 +1587,7 @@ var MlsGroupKeyProvider = class {
1225
1587
  async ensureSessionUnlocked(channelId, opts) {
1226
1588
  if (!opts.forceReinit && await this.o.store.load(channelId))
1227
1589
  return;
1590
+ await this.ensureCryptoReady();
1228
1591
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1229
1592
  if (!bundle && (opts.bootstrap || opts.forceReinit)) {
1230
1593
  await this.publishKeyPackage();
@@ -1255,7 +1618,6 @@ var MlsGroupKeyProvider = class {
1255
1618
  });
1256
1619
  await this.o.store.save(channelId, mgr.exportState());
1257
1620
  this.managers.set(channelId, mgr);
1258
- await this.o.store.deletePendingBundle(this.o.deviceId);
1259
1621
  return;
1260
1622
  }
1261
1623
  if (opts.bootstrap) {
@@ -1283,30 +1645,23 @@ var MlsGroupKeyProvider = class {
1283
1645
  });
1284
1646
  this.managers.set(channelId, mgr);
1285
1647
  await this.o.store.save(channelId, mgr.exportState());
1286
- await this.o.store.deletePendingBundle(this.o.deviceId);
1287
1648
  } else {
1288
1649
  const rows = await this.o.backend.pullDelivery(this.o.deviceId);
1289
1650
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
1290
1651
  if (!w)
1291
1652
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
1292
1653
  const mgr = new MLSGroupManager();
1293
- await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1654
+ try {
1655
+ await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1656
+ } catch (err) {
1657
+ this.recordDecryptError(w, err);
1658
+ console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
1659
+ await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1660
+ return;
1661
+ }
1294
1662
  this.managers.set(channelId, mgr);
1295
1663
  await this.o.store.save(channelId, mgr.exportState());
1296
1664
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1297
- const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
1298
- let burstPending = false;
1299
- for (const r of remaining) {
1300
- if (r.message_type !== "welcome")
1301
- continue;
1302
- const cid = r.connect_channel_id;
1303
- if (!cid || !await this.o.store.load(cid)) {
1304
- burstPending = true;
1305
- break;
1306
- }
1307
- }
1308
- if (!burstPending)
1309
- await this.o.store.deletePendingBundle(this.o.deviceId);
1310
1665
  }
1311
1666
  }
1312
1667
  /**
@@ -1325,7 +1680,7 @@ var MlsGroupKeyProvider = class {
1325
1680
  async addMemberToLiveGroup(channelId, newDeviceId) {
1326
1681
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
1327
1682
  }
1328
- async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt) {
1683
+ async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
1329
1684
  const mgr = await this.manager(channelId);
1330
1685
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
1331
1686
  const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
@@ -1333,6 +1688,13 @@ var MlsGroupKeyProvider = class {
1333
1688
  if (!hex)
1334
1689
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
1335
1690
  const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1691
+ if (opts?.expectedIdentityId) {
1692
+ const cred = pkg.leafNode.credential;
1693
+ const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
1694
+ if (claimed !== opts.expectedIdentityId) {
1695
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
1696
+ }
1697
+ }
1336
1698
  const priorEpoch = Number(mgr.epoch);
1337
1699
  const { commit, welcome } = await mgr.addMembers([pkg]);
1338
1700
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -1354,7 +1716,13 @@ var MlsGroupKeyProvider = class {
1354
1716
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
1355
1717
  } catch {
1356
1718
  }
1357
- return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1);
1719
+ try {
1720
+ const work = await this.o.backend.getCommitterWork(channelId);
1721
+ if (!work.adds.includes(newDeviceId))
1722
+ return;
1723
+ } catch {
1724
+ }
1725
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
1358
1726
  }
1359
1727
  await this.o.store.save(channelId, mgr.exportState());
1360
1728
  }
@@ -1546,7 +1914,7 @@ var MlsGroupKeyProvider = class {
1546
1914
  * Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
1547
1915
  * addMemberToLiveGroupUnlocked) — no weaker path is introduced.
1548
1916
  */
1549
- async healDivergedMember(channelId, memberDeviceId) {
1917
+ async healDivergedMember(channelId, memberDeviceId, opts) {
1550
1918
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
1551
1919
  const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
1552
1920
  const hex = kps[memberDeviceId];
@@ -1557,6 +1925,9 @@ var MlsGroupKeyProvider = class {
1557
1925
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
1558
1926
  }
1559
1927
  const identity = new TextDecoder().decode(cred.identity);
1928
+ if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
1929
+ throw new ForgedCredentialError(opts.expectedIdentityId, identity);
1930
+ }
1560
1931
  const mgr = await this.manager(channelId);
1561
1932
  const leaves = mgr.leafIndicesForIdentity(identity);
1562
1933
  if (leaves.length > 1) {
@@ -1573,7 +1944,7 @@ var MlsGroupKeyProvider = class {
1573
1944
  }
1574
1945
  }
1575
1946
  try {
1576
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0);
1947
+ await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
1577
1948
  } catch (e) {
1578
1949
  if (removeLanded)
1579
1950
  throw new HealAddAfterRemoveError(e);
@@ -1581,6 +1952,48 @@ var MlsGroupKeyProvider = class {
1581
1952
  }
1582
1953
  }));
1583
1954
  }
1955
+ /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
1956
+ * epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
1957
+ * re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
1958
+ * sweep call sites and for the cross-tenant authz that differs only server-side.
1959
+ * Caller contract (like the member path): the guest republishes a fresh
1960
+ * KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
1961
+ *
1962
+ * Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
1963
+ * retained KeyPackage (the one the caller confirmed the guest republished).
1964
+ * Before doing anything else we fetch it BY ID and refuse
1965
+ * ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
1966
+ * strength of "some KeyPackage is published for this device", because that
1967
+ * could be stale, consumed, or absent and would seal an un-openable Welcome,
1968
+ * bricking the guest instead of healing it. Once the gate passes we delegate to
1969
+ * the proven {@link healDivergedMember}: given `publish_key_package`'s
1970
+ * delete-and-replace semantics and the guest's pending-bundle republish guard,
1971
+ * the by-device fetch inside healDivergedMember resolves to exactly this same
1972
+ * KeyPackage — the id-fetch here is the fail-closed contract, the member heal
1973
+ * is the mechanism. */
1974
+ async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
1975
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
1976
+ if (!hex)
1977
+ throw new GuestHealStaleKpError(expectedKpId);
1978
+ return this.healDivergedMember(channelId, guestDeviceId);
1979
+ }
1980
+ /** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
1981
+ * AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
1982
+ * id-by-value gate (refuse if `expectedKpId` is gone/consumed →
1983
+ * {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
1984
+ * delegates to {@link healDivergedMember} with `expectedIdentityId` so the
1985
+ * committed-bytes assertion binds the re-Added leaf to the intended identity
1986
+ * (throws {@link ForgedCredentialError} on a credential-identity mismatch,
1987
+ * including on the 409-retry path). This is the entry point the owner's
1988
+ * agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
1989
+ * the welcome-request row the agent published, so a compromised agent cannot
1990
+ * substitute a KeyPackage that forges a different identity. */
1991
+ async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
1992
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
1993
+ if (!hex)
1994
+ throw new GuestHealStaleKpError(expectedKpId);
1995
+ return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
1996
+ }
1584
1997
  /**
1585
1998
  * Member-side: drop the diverged local state for a channel so a subsequent
1586
1999
  * {@link joinAndReconcile} consumes the committer's fresh Welcome (from
@@ -1615,6 +2028,56 @@ var MlsGroupKeyProvider = class {
1615
2028
  this.errors.delete(channelId);
1616
2029
  });
1617
2030
  }
2031
+ /** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
2032
+ * ensureSession forceReinit branch but posts a client-chosen new_group_id +
2033
+ * signed attestation to the reset-mode endpoint, then builds + distributes
2034
+ * the new group. Runs under the channel lock (serialized against
2035
+ * receiveOnce / committer ops).
2036
+ *
2037
+ * `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
2038
+ * member device holding an unconsumed KeyPackage (H2) -- the caller (the web
2039
+ * client's members-endpoint enumeration) is responsible for that union;
2040
+ * this method only handles the mechanical THIS-device exclusion: this
2041
+ * device is the committer that creates the group, so it must not be in the
2042
+ * MLS addMembers set (adding your own new leaf via addMembers is
2043
+ * nonsensical / unsupported). The wire member_device_ids below re-includes
2044
+ * it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
2045
+ * (required subset of supplied) is still satisfied even though it never
2046
+ * goes through addMembers. */
2047
+ async resetSessionSupersede(channelId, opts) {
2048
+ return this.locks.run(channelId, async () => {
2049
+ let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2050
+ if (!bundle) {
2051
+ await this.publishKeyPackage();
2052
+ bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2053
+ }
2054
+ if (!bundle)
2055
+ throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2056
+ const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2057
+ const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2058
+ const mgr = new MLSGroupManager();
2059
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2060
+ const kps = await this.o.backend.fetchKeyPackages(others);
2061
+ const pkgs = others.map((d) => {
2062
+ const hex = kps[d];
2063
+ if (!hex)
2064
+ throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2065
+ return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2066
+ });
2067
+ const { commit, welcome } = await mgr.addMembers(pkgs);
2068
+ await this.o.backend.distribute(reinit.group_id, {
2069
+ device_id: this.o.deviceId,
2070
+ commit: bytesToHex(commit),
2071
+ commit_epoch: Number(mgr.epoch),
2072
+ expected_epoch: reinit.current_epoch,
2073
+ member_device_ids: [this.o.deviceId, ...others],
2074
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
2075
+ });
2076
+ await this.o.store.save(channelId, mgr.exportState());
2077
+ this.managers.set(channelId, mgr);
2078
+ await this.o.store.deletePendingBundle(this.o.deviceId);
2079
+ });
2080
+ }
1618
2081
  /** Returns true when an in-memory MLS manager is live for the channel.
1619
2082
  * Cheap synchronous check; does NOT consult the store. */
1620
2083
  hasChannelState(channelId) {
@@ -1628,6 +2091,19 @@ var MlsGroupKeyProvider = class {
1628
2091
  return true;
1629
2092
  return await this.o.store.load(channelId) !== null;
1630
2093
  }
2094
+ /**
2095
+ * Return the number of leaves the given identity holds in the live tree for
2096
+ * `channelId`. Loads the manager from the store if not already cached.
2097
+ *
2098
+ * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2099
+ * does NOT create a duplicate leaf for an already-added device. A return value
2100
+ * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2101
+ * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2102
+ */
2103
+ async leafCountForIdentity(channelId, identityId) {
2104
+ const mgr = await this.manager(channelId);
2105
+ return mgr.leafIndicesForIdentity(identityId).length;
2106
+ }
1631
2107
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
1632
2108
  * least one leaf bearing `identityId` as its credential. Used by the agent
1633
2109
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -1647,18 +2123,130 @@ var MlsGroupKeyProvider = class {
1647
2123
  const r = await this.o.backend.getConnectChannelGroup(channelId);
1648
2124
  return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
1649
2125
  }
2126
+ /**
2127
+ * Verified group supersede for the agent's receive pump (owner-attested reset).
2128
+ * Returns true when the channel's state was swapped to the new group and the
2129
+ * Welcome row acked; false when the supersede was REFUSED (row left un-acked).
2130
+ *
2131
+ * FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
2132
+ * ⇒ refuse, never call verify), then structural signer check, then attestation
2133
+ * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
2134
+ */
2135
+ async trySupersede(channelId, row, heldGroupId) {
2136
+ const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
2137
+ if (!owner || !owner.publicKeyHex) {
2138
+ this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
2139
+ return false;
2140
+ }
2141
+ const retired = await this.o.store.loadRetiredGroupIds(channelId);
2142
+ if (retired.includes(row.group_id)) {
2143
+ this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
2144
+ return false;
2145
+ }
2146
+ const att = await this.o.backend.fetchResetAttestation(row.group_id);
2147
+ if (!att) {
2148
+ this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
2149
+ return false;
2150
+ }
2151
+ if (att.signerIdentityId !== owner.identityId) {
2152
+ this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
2153
+ return false;
2154
+ }
2155
+ let content;
2156
+ try {
2157
+ content = JSON.parse(att.signedContent);
2158
+ } catch {
2159
+ this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
2160
+ return false;
2161
+ }
2162
+ if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
2163
+ this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
2164
+ return false;
2165
+ }
2166
+ const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
2167
+ if (!ok) {
2168
+ this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
2169
+ return false;
2170
+ }
2171
+ const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2172
+ if (!bundle) {
2173
+ this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
2174
+ return false;
2175
+ }
2176
+ return await this.locks.run(channelId, async () => {
2177
+ const mgr = new MLSGroupManager();
2178
+ try {
2179
+ await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2180
+ } catch (err) {
2181
+ this.recordDecryptError(row, err);
2182
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2183
+ console.warn(`[trace] supersede joinFromWelcome FAILED ch=${channelId.slice(0, 8)} g=${row.group_id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)} -> self-heal`);
2184
+ }
2185
+ let requested = false;
2186
+ try {
2187
+ requested = await this.o.onSupersedeUnopenable?.(channelId, row.group_id) ?? false;
2188
+ } catch {
2189
+ }
2190
+ if (requested) {
2191
+ try {
2192
+ await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
2193
+ } catch {
2194
+ }
2195
+ }
2196
+ return false;
2197
+ }
2198
+ if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2199
+ this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2200
+ return false;
2201
+ }
2202
+ this.evictChannelState(channelId);
2203
+ await this.o.store.delete(channelId);
2204
+ await this.o.store.discardStaging(channelId);
2205
+ await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2206
+ this.errors.delete(channelId);
2207
+ this.managers.set(channelId, mgr);
2208
+ await this.o.store.save(channelId, mgr.exportState());
2209
+ await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
2210
+ return true;
2211
+ });
2212
+ }
1650
2213
  async receiveOnce(deviceId) {
1651
2214
  const rows = await this.o.backend.pullDelivery(deviceId);
2215
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2216
+ console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2217
+ }
1652
2218
  const appEntries = [];
1653
2219
  const handshakeAcked = [];
2220
+ const supersededChannelIds = [];
1654
2221
  const order = { welcome: 0, commit: 1, application: 2 };
1655
2222
  rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
1656
2223
  for (const row of rows) {
1657
2224
  try {
1658
2225
  if (row.message_type === "welcome") {
1659
2226
  const wcid = row.connect_channel_id;
1660
- if (wcid && await this.o.store.load(wcid)) {
1661
- handshakeAcked.push(row.queue_id);
2227
+ if (wcid) {
2228
+ const existing = await this.o.store.load(wcid);
2229
+ if (existing) {
2230
+ let heldGroupId = null;
2231
+ try {
2232
+ const mgr = await this.manager(wcid);
2233
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2234
+ } catch {
2235
+ heldGroupId = null;
2236
+ }
2237
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2238
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} heldGroup=${heldGroupId ? heldGroupId.slice(0, 8) : "NULL"} -> ${heldGroupId === null ? "SKIP(manager-unloadable)" : heldGroupId === row.group_id ? "stray-ack" : "trySupersede"}`);
2239
+ }
2240
+ if (heldGroupId !== null && heldGroupId === row.group_id) {
2241
+ handshakeAcked.push(row.queue_id);
2242
+ } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2243
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2244
+ if (ok)
2245
+ supersededChannelIds.push(wcid);
2246
+ }
2247
+ } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2248
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
2249
+ }
1662
2250
  }
1663
2251
  } else if (row.message_type === "commit") {
1664
2252
  const cid = row.connect_channel_id;
@@ -1692,12 +2280,14 @@ var MlsGroupKeyProvider = class {
1692
2280
  plaintext,
1693
2281
  envelope,
1694
2282
  receivedAt: Date.now(),
1695
- retryCount: 0
2283
+ retryCount: 0,
2284
+ expires_at: row.expires_at
1696
2285
  });
1697
2286
  }
1698
2287
  } catch (err) {
1699
2288
  this.recordDecryptError(row, err);
1700
2289
  const cid = row.connect_channel_id;
2290
+ let drained = false;
1701
2291
  if (cid && typeof row.epoch === "number") {
1702
2292
  let liveEpoch;
1703
2293
  try {
@@ -1709,12 +2299,27 @@ var MlsGroupKeyProvider = class {
1709
2299
  if (permanentlyDead) {
1710
2300
  handshakeAcked.push(row.queue_id);
1711
2301
  this.permanentlySkipped++;
2302
+ drained = true;
2303
+ }
2304
+ }
2305
+ if (!drained && cid) {
2306
+ const e = this.errors.get(cid);
2307
+ if (e) {
2308
+ e.wedgedRun += 1;
2309
+ if (e.wedgedRun >= this.quarantineThreshold) {
2310
+ handshakeAcked.push(row.queue_id);
2311
+ this.quarantineDrained++;
2312
+ this.quarantineChannel(cid);
2313
+ }
1712
2314
  }
1713
2315
  }
1714
2316
  }
1715
2317
  }
1716
2318
  if (handshakeAcked.length)
1717
2319
  await this.o.backend.ackDelivery(deviceId, handshakeAcked);
2320
+ if (supersededChannelIds.length) {
2321
+ Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
2322
+ }
1718
2323
  return appEntries;
1719
2324
  }
1720
2325
  /**
@@ -1795,11 +2400,38 @@ var FRAME_PARSERS = {
1795
2400
  roomId: d?.room_id ? str(d.room_id) : void 0,
1796
2401
  requestingDeviceId: str(d?.requesting_device_id),
1797
2402
  requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
2403
+ freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
1798
2404
  requestId: d?.request_id ? str(d.request_id) : void 0
1799
2405
  }
2406
+ }),
2407
+ // Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
2408
+ // heal a guest whose ratchet tree diverged. Durable state is the
2409
+ // mls_guest_heal_request row; this frame is just the wakeup hint.
2410
+ mls_guest_heal_requested: (d) => ({
2411
+ guestHealRequest: {
2412
+ groupId: str(d?.group_id),
2413
+ channelId: str(d?.channel_id),
2414
+ enclaveId: str(d?.enclave_id),
2415
+ guestIdentityId: str(d?.guest_identity_id),
2416
+ guestDeviceId: str(d?.guest_device_id),
2417
+ freshKpId: str(d?.fresh_kp_id),
2418
+ requestId: str(d?.request_id)
2419
+ }
1800
2420
  })
1801
2421
  };
1802
2422
  var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
2423
+ var TERMINAL_FRAME_EVENTS = /* @__PURE__ */ new Set([
2424
+ "device_revoked",
2425
+ "identity.revoked"
2426
+ ]);
2427
+ function terminalFrameReason(frame) {
2428
+ if (!frame || typeof frame !== "object")
2429
+ return void 0;
2430
+ const f = frame;
2431
+ if (typeof f.event !== "string" || !TERMINAL_FRAME_EVENTS.has(f.event))
2432
+ return void 0;
2433
+ return typeof f.data?.reason === "string" && f.data.reason ? f.data.reason : f.event;
2434
+ }
1803
2435
  function parseServerFrame(frame) {
1804
2436
  if (!frame || typeof frame !== "object")
1805
2437
  return void 0;
@@ -1815,8 +2447,22 @@ function openDeliveryPings(o) {
1815
2447
  const rng = o.rng ?? Math.random;
1816
2448
  const maxQueue = o.maxQueue ?? 8;
1817
2449
  const stableResetMs = o.stableResetMs ?? 1e4;
2450
+ const hardened = o.hardened ?? false;
2451
+ const now = o.now ?? (() => Date.now());
2452
+ const silenceMs = o.silenceMs ?? 9e4;
2453
+ const watchdogMs = o.watchdogMs ?? 3e4;
2454
+ const wakeGapMs = o.wakeGapMs ?? 12e4;
2455
+ const authWarCap = o.authWarCap ?? 3;
2456
+ const authWarWindowMs = o.authWarWindowMs ?? 12e4;
2457
+ const lifecycle = (event, detail) => {
2458
+ try {
2459
+ o.onLifecycle?.(event, detail);
2460
+ } catch {
2461
+ }
2462
+ };
1818
2463
  let closed = false;
1819
2464
  let attempt = 0;
2465
+ let connectGen = 0;
1820
2466
  let sock;
1821
2467
  let backoffTimer;
1822
2468
  let stableTimer;
@@ -1828,6 +2474,17 @@ function openDeliveryPings(o) {
1828
2474
  pongTimer = void 0;
1829
2475
  }
1830
2476
  };
2477
+ let terminalReason;
2478
+ let lastInboundAt = 0;
2479
+ let lastTickAt;
2480
+ let watchdogTimer;
2481
+ const authCloses = [];
2482
+ const stopWatchdog = () => {
2483
+ if (watchdogTimer) {
2484
+ clearInterval(watchdogTimer);
2485
+ watchdogTimer = void 0;
2486
+ }
2487
+ };
1831
2488
  const queue = [];
1832
2489
  let waiter;
1833
2490
  let iterStarted = false;
@@ -1847,9 +2504,18 @@ function openDeliveryPings(o) {
1847
2504
  if (stableTimer)
1848
2505
  clearTimeout(stableTimer);
1849
2506
  stopPong();
1850
- try {
1851
- sock?.close();
1852
- } catch {
2507
+ stopWatchdog();
2508
+ const ws = sock;
2509
+ sock = void 0;
2510
+ if (ws) {
2511
+ ws.onopen = null;
2512
+ ws.onmessage = null;
2513
+ ws.onclose = null;
2514
+ ws.onerror = null;
2515
+ try {
2516
+ ws.close();
2517
+ } catch {
2518
+ }
1853
2519
  }
1854
2520
  if (waiter) {
1855
2521
  const w = waiter;
@@ -1860,13 +2526,60 @@ function openDeliveryPings(o) {
1860
2526
  const scheduleReconnect = () => {
1861
2527
  if (closed)
1862
2528
  return;
2529
+ if (backoffTimer)
2530
+ clearTimeout(backoffTimer);
2531
+ connectGen += 1;
1863
2532
  attempt += 1;
1864
2533
  const ceil = Math.min(cap, base * 2 ** (attempt - 1));
1865
2534
  backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
1866
2535
  };
2536
+ const forceCycle = (event, detail) => {
2537
+ if (closed)
2538
+ return;
2539
+ lifecycle(event, detail);
2540
+ const ws = sock;
2541
+ sock = void 0;
2542
+ if (stableTimer)
2543
+ clearTimeout(stableTimer);
2544
+ stopPong();
2545
+ if (ws) {
2546
+ ws.onopen = null;
2547
+ ws.onmessage = null;
2548
+ ws.onclose = null;
2549
+ ws.onerror = null;
2550
+ try {
2551
+ ws.close();
2552
+ } catch {
2553
+ }
2554
+ }
2555
+ scheduleReconnect();
2556
+ };
2557
+ const startWatchdog = () => {
2558
+ if (!hardened || watchdogTimer)
2559
+ return;
2560
+ lastTickAt = now();
2561
+ watchdogTimer = setInterval(() => {
2562
+ if (closed)
2563
+ return;
2564
+ const t = now();
2565
+ const gap = lastTickAt !== void 0 ? t - lastTickAt : 0;
2566
+ if (lastTickAt !== void 0 && gap > wakeGapMs) {
2567
+ lastTickAt = t;
2568
+ attempt = 0;
2569
+ forceCycle("wake", `${gap}ms gap`);
2570
+ return;
2571
+ }
2572
+ lastTickAt = t;
2573
+ if (sock && sock.readyState === 1 && t - lastInboundAt > silenceMs) {
2574
+ forceCycle("halfopen", `${t - lastInboundAt}ms silent`);
2575
+ }
2576
+ }, watchdogMs);
2577
+ };
1867
2578
  async function connect() {
1868
2579
  if (closed)
1869
2580
  return;
2581
+ const gen = connectGen;
2582
+ terminalReason = void 0;
1870
2583
  let token;
1871
2584
  try {
1872
2585
  token = await o.tokenProvider();
@@ -1874,15 +2587,17 @@ function openDeliveryPings(o) {
1874
2587
  scheduleReconnect();
1875
2588
  return;
1876
2589
  }
1877
- if (closed)
2590
+ if (closed || gen !== connectGen)
1878
2591
  return;
1879
2592
  const sep = o.url.includes("?") ? "&" : "?";
1880
2593
  const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
1881
2594
  sock = ws;
1882
2595
  ws.onopen = () => {
1883
2596
  push({ reconnected: true });
2597
+ lastInboundAt = now();
1884
2598
  stableTimer = setTimeout(() => {
1885
2599
  attempt = 0;
2600
+ authCloses.length = 0;
1886
2601
  }, stableResetMs);
1887
2602
  stopPong();
1888
2603
  pongTimer = setInterval(() => {
@@ -1894,8 +2609,26 @@ function openDeliveryPings(o) {
1894
2609
  }, PRESENCE_PONG_MS);
1895
2610
  };
1896
2611
  ws.onmessage = (e) => {
2612
+ lastInboundAt = now();
2613
+ let f;
2614
+ try {
2615
+ f = JSON.parse(String(e.data));
2616
+ } catch {
2617
+ return;
2618
+ }
2619
+ if (hardened) {
2620
+ const tr = terminalFrameReason(f);
2621
+ if (tr) {
2622
+ terminalReason = tr;
2623
+ } else if (f?.event === "ping") {
2624
+ try {
2625
+ if (ws.readyState === 1)
2626
+ ws.send(JSON.stringify({ event: "pong" }));
2627
+ } catch {
2628
+ }
2629
+ }
2630
+ }
1897
2631
  try {
1898
- const f = JSON.parse(String(e.data));
1899
2632
  const ping = parseServerFrame(f);
1900
2633
  if (ping)
1901
2634
  push(ping);
@@ -1911,14 +2644,41 @@ function openDeliveryPings(o) {
1911
2644
  stopPong();
1912
2645
  if (closed)
1913
2646
  return;
1914
- if (e.code === 4001) {
2647
+ const code = e.code;
2648
+ if (!hardened) {
2649
+ if (code === 4001) {
2650
+ end();
2651
+ return;
2652
+ }
2653
+ scheduleReconnect();
2654
+ return;
2655
+ }
2656
+ const tr = terminalReason;
2657
+ terminalReason = void 0;
2658
+ if (code === 4006 || tr && code === 4001) {
2659
+ lifecycle("terminal", tr ?? `code ${code}`);
1915
2660
  end();
1916
2661
  return;
1917
2662
  }
2663
+ if (code === 4001) {
2664
+ const t = now();
2665
+ authCloses.push(t);
2666
+ while (authCloses.length && t - authCloses[0] > authWarWindowMs)
2667
+ authCloses.shift();
2668
+ if (authCloses.length >= authWarCap) {
2669
+ lifecycle("terminal", `auth war-cap ${authCloses.length}/${authWarWindowMs}ms`);
2670
+ end();
2671
+ return;
2672
+ }
2673
+ lifecycle("reconnect", "auth-4001");
2674
+ scheduleReconnect();
2675
+ return;
2676
+ }
1918
2677
  scheduleReconnect();
1919
2678
  };
1920
2679
  }
1921
2680
  void connect();
2681
+ startWatchdog();
1922
2682
  const pings = {
1923
2683
  [Symbol.asyncIterator]() {
1924
2684
  if (iterStarted)
@@ -2033,6 +2793,11 @@ var RestTransport = class {
2033
2793
  if (stale)
2034
2794
  throw stale;
2035
2795
  }
2796
+ if (res.status === 403) {
2797
+ const paused = await agentPausedErrorFromResponse(res.clone ? res.clone() : res);
2798
+ if (paused)
2799
+ throw paused;
2800
+ }
2036
2801
  if (!res.ok)
2037
2802
  throw new Error(`publish failed: ${res.status}`);
2038
2803
  const j = await res.json();
@@ -2048,7 +2813,13 @@ var RestTransport = class {
2048
2813
  if (this.opts.connectIdentityId) {
2049
2814
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
2050
2815
  }
2051
- const handle = openDeliveryPings({ url: wsUrl, tokenProvider: this.tokenProvider, wsImpl: this.ws });
2816
+ const handle = openDeliveryPings({
2817
+ url: wsUrl,
2818
+ tokenProvider: this.tokenProvider,
2819
+ wsImpl: this.ws,
2820
+ hardened: this.opts.hardenedReconnect ?? false,
2821
+ onLifecycle: this.opts.onWsLifecycle
2822
+ });
2052
2823
  this._controlSend = handle.send;
2053
2824
  const close = () => {
2054
2825
  if (this._controlSend === handle.send)
@@ -2123,10 +2894,11 @@ var RestMlsBackend = class {
2123
2894
  return { device_id: String(j.device_id) };
2124
2895
  }
2125
2896
  async publishKeyPackage(deviceId, keyPackageHex) {
2126
- await this.post("/api/v1/mls/key-packages", {
2897
+ const j = await this.post("/api/v1/mls/key-packages", {
2127
2898
  key_package: keyPackageHex,
2128
2899
  device_id: deviceId
2129
2900
  });
2901
+ return String(j.id);
2130
2902
  }
2131
2903
  async getConnectChannelGroup(channelId) {
2132
2904
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
@@ -2163,10 +2935,37 @@ var RestMlsBackend = class {
2163
2935
  member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
2164
2936
  };
2165
2937
  }
2166
- async fetchKeyPackages(deviceIds) {
2938
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
2939
+ const committer = memberDeviceIds[0];
2940
+ if (!committer)
2941
+ throw new Error("resetReinitConnectChannelGroup: empty member set");
2942
+ const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
2943
+ member_device_ids: memberDeviceIds,
2944
+ committer_device_id: committer,
2945
+ mode: "reset",
2946
+ new_group_id: att.newGroupId,
2947
+ reset_attestation_signature: att.signatureHex,
2948
+ reset_attestation_nonce: att.nonceHex,
2949
+ reset_attestation_content: att.signedContentHex
2950
+ });
2951
+ return {
2952
+ group_id: String(j.group_id),
2953
+ current_epoch: Number(j.current_epoch),
2954
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
2955
+ };
2956
+ }
2957
+ async fetchKeyPackages(deviceIds) {
2167
2958
  const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
2168
2959
  return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
2169
2960
  }
2961
+ async fetchKeyPackageById(kpId) {
2962
+ try {
2963
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
2964
+ return String(j.key_package);
2965
+ } catch {
2966
+ return null;
2967
+ }
2968
+ }
2170
2969
  async distribute(groupId, req) {
2171
2970
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
2172
2971
  ...req,
@@ -2183,13 +2982,48 @@ var RestMlsBackend = class {
2183
2982
  queue_ids: queueIds
2184
2983
  });
2185
2984
  }
2186
- async requestWelcome(groupId, deviceId) {
2187
- await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, { device_id: deviceId });
2985
+ async requestWelcome(groupId, deviceId, freshKpId) {
2986
+ const body = { device_id: deviceId };
2987
+ if (freshKpId)
2988
+ body.fresh_kp_id = freshKpId;
2989
+ await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
2990
+ }
2991
+ async getCommitterWork(channelId) {
2992
+ const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
2993
+ return {
2994
+ adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
2995
+ removes: Array.isArray(j.removes) ? j.removes.map(String) : []
2996
+ };
2997
+ }
2998
+ async acquireCommitLease(channelId) {
2999
+ const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
3000
+ if (res.status === 409 || res.status === 503)
3001
+ return null;
3002
+ if (!res.ok)
3003
+ throw new Error(`commit-lease ${res.status}`);
3004
+ const j = await res.json();
3005
+ return String(j.token);
3006
+ }
3007
+ async releaseCommitLease(channelId, token) {
3008
+ await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
3009
+ });
3010
+ }
3011
+ async fetchResetAttestation(groupId) {
3012
+ try {
3013
+ const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
3014
+ return {
3015
+ signedContent: String(j.signed_content),
3016
+ signatureHex: String(j.signature_hex),
3017
+ signerIdentityId: String(j.signer_identity_id)
3018
+ };
3019
+ } catch {
3020
+ return null;
3021
+ }
2188
3022
  }
2189
3023
  };
2190
3024
 
2191
3025
  // ../connect-sdk-ts/dist/mls/fake-backend.js
2192
- import { bytesToHex as bytesToHex2 } from "@agentvault/crypto";
3026
+ import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
2193
3027
 
2194
3028
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
2195
3029
  import { hkdf } from "@noble/hashes/hkdf";
@@ -2239,9 +3073,10 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
2239
3073
 
2240
3074
  // ../connect-mls-store/dist/sqlite-store.js
2241
3075
  import { DatabaseSync } from "node:sqlite";
3076
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
2242
3077
 
2243
3078
  // ../connect-mls-store/dist/schema.js
2244
- var SCHEMA_VERSION = 1;
3079
+ var SCHEMA_VERSION = 4;
2245
3080
  function openSchema(db) {
2246
3081
  db.exec("PRAGMA journal_mode=WAL");
2247
3082
  const row = db.prepare("PRAGMA user_version").get();
@@ -2256,8 +3091,39 @@ function openSchema(db) {
2256
3091
  PRIMARY KEY (identity_id, channel_id)
2257
3092
  );
2258
3093
  `);
2259
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2260
3094
  }
3095
+ if (row.user_version < 2) {
3096
+ db.exec(`
3097
+ CREATE TABLE IF NOT EXISTS mls_identity_pin (
3098
+ owner_identity_id TEXT PRIMARY KEY,
3099
+ public_key_hex TEXT NOT NULL,
3100
+ key_algo TEXT NOT NULL,
3101
+ pinned_at INTEGER NOT NULL
3102
+ );
3103
+ `);
3104
+ }
3105
+ if (row.user_version < 3) {
3106
+ db.exec(`
3107
+ CREATE TABLE IF NOT EXISTS mls_retired_group (
3108
+ channel_id TEXT NOT NULL,
3109
+ group_id TEXT NOT NULL,
3110
+ PRIMARY KEY (channel_id, group_id)
3111
+ );
3112
+ `);
3113
+ }
3114
+ if (row.user_version < 4) {
3115
+ db.exec(`
3116
+ CREATE TABLE IF NOT EXISTS mls_pending_bundle (
3117
+ identity_id TEXT NOT NULL,
3118
+ ref TEXT NOT NULL,
3119
+ kp_id TEXT,
3120
+ sealed_bundle BLOB,
3121
+ updated_at INTEGER NOT NULL,
3122
+ PRIMARY KEY (identity_id, ref)
3123
+ );
3124
+ `);
3125
+ }
3126
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2261
3127
  }
2262
3128
 
2263
3129
  // ../connect-mls-store/dist/errors.js
@@ -2275,13 +3141,39 @@ var MlsStoreSealError = class extends Error {
2275
3141
  };
2276
3142
 
2277
3143
  // ../connect-mls-store/dist/sqlite-store.js
3144
+ function encodeBundle(bundle) {
3145
+ const pp = bundle.privatePackage;
3146
+ const obj = {
3147
+ pub: bytesToHex3(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
3148
+ initPriv: bytesToHex3(pp.initPrivateKey),
3149
+ hpkePriv: bytesToHex3(pp.hpkePrivateKey),
3150
+ sigPriv: bytesToHex3(pp.signaturePrivateKey)
3151
+ };
3152
+ return new TextEncoder().encode(JSON.stringify(obj));
3153
+ }
3154
+ function decodeBundle(bytes) {
3155
+ const obj = JSON.parse(new TextDecoder().decode(bytes));
3156
+ return {
3157
+ publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
3158
+ privatePackage: {
3159
+ initPrivateKey: hexToBytes3(obj.initPriv),
3160
+ hpkePrivateKey: hexToBytes3(obj.hpkePriv),
3161
+ signaturePrivateKey: hexToBytes3(obj.sigPriv)
3162
+ }
3163
+ };
3164
+ }
2278
3165
  var SqliteMlsStateStore = class {
2279
3166
  db;
2280
3167
  id;
2281
3168
  cipher;
2282
- // Bundles stay in-memory (spec §4): private half not durably serializable.
2283
- // Per-instance map → per-identity store instances are naturally isolated (§6.1).
3169
+ // In-memory write-through cache over the durable mls_pending_bundle table.
3170
+ // Persisting the bundle (schema v4) is the root-cause fix for restart-induced
3171
+ // KeyPackage rotation: with the bundle in-memory ONLY, every restart lost it,
3172
+ // re-minted+re-published a fresh KP, and orphaned every Welcome sealed to the
3173
+ // prior published KP. The cache avoids re-unsealing on every access; sqlite is
3174
+ // the source of truth across process restarts.
2284
3175
  bundles = /* @__PURE__ */ new Map();
3176
+ lastKpIds = /* @__PURE__ */ new Map();
2285
3177
  constructor(opts) {
2286
3178
  this.id = opts.identityId;
2287
3179
  this.cipher = opts.cipherFor(opts.identityId);
@@ -2350,12 +3242,74 @@ var SqliteMlsStateStore = class {
2350
3242
  }
2351
3243
  async savePendingBundle(ref, bundle) {
2352
3244
  this.bundles.set(ref, bundle);
3245
+ const sealed = await this.cipher.seal(encodeBundle(bundle));
3246
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, sealed_bundle, updated_at)
3247
+ VALUES (?,?,?,?)
3248
+ ON CONFLICT(identity_id, ref) DO UPDATE SET sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, ref, sealed, Date.now());
2353
3249
  }
2354
3250
  async loadPendingBundle(ref) {
2355
- return this.bundles.get(ref) ?? null;
3251
+ const cached = this.bundles.get(ref);
3252
+ if (cached)
3253
+ return cached;
3254
+ const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
3255
+ if (!row?.sealed_bundle)
3256
+ return null;
3257
+ let bundle;
3258
+ try {
3259
+ bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
3260
+ } catch (e) {
3261
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
3262
+ console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3263
+ }
3264
+ return null;
3265
+ }
3266
+ this.bundles.set(ref, bundle);
3267
+ return bundle;
2356
3268
  }
2357
3269
  async deletePendingBundle(ref) {
2358
3270
  this.bundles.delete(ref);
3271
+ this.db.prepare("UPDATE mls_pending_bundle SET sealed_bundle=NULL, updated_at=? WHERE identity_id=? AND ref=?").run(Date.now(), this.id, ref);
3272
+ }
3273
+ async saveLastKpId(deviceId, id) {
3274
+ this.lastKpIds.set(deviceId, id);
3275
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, updated_at)
3276
+ VALUES (?,?,?,?)
3277
+ ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, updated_at=excluded.updated_at`).run(this.id, deviceId, id, Date.now());
3278
+ }
3279
+ async loadLastKpId(deviceId) {
3280
+ const cached = this.lastKpIds.get(deviceId);
3281
+ if (cached)
3282
+ return cached;
3283
+ const row = this.db.prepare("SELECT kp_id FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, deviceId);
3284
+ if (!row?.kp_id)
3285
+ return null;
3286
+ this.lastKpIds.set(deviceId, row.kp_id);
3287
+ return row.kp_id;
3288
+ }
3289
+ async saveLastKpAndBundle(deviceId, id, bundle) {
3290
+ this.lastKpIds.set(deviceId, id);
3291
+ this.bundles.set(deviceId, bundle);
3292
+ const sealed = await this.cipher.seal(encodeBundle(bundle));
3293
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, sealed_bundle, updated_at)
3294
+ VALUES (?,?,?,?,?)
3295
+ ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, deviceId, id, sealed, Date.now());
3296
+ }
3297
+ async savePinnedIdentityKey(identityId, key) {
3298
+ this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
3299
+ VALUES (?,?,?,?)
3300
+ ON CONFLICT(owner_identity_id) DO UPDATE SET
3301
+ public_key_hex=excluded.public_key_hex, key_algo=excluded.key_algo, pinned_at=excluded.pinned_at`).run(identityId, key.publicKeyHex, key.keyAlgo, Date.now());
3302
+ }
3303
+ async loadPinnedIdentityKey(identityId) {
3304
+ const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
3305
+ return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
3306
+ }
3307
+ async addRetiredGroupId(channelId, groupId) {
3308
+ this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
3309
+ }
3310
+ async loadRetiredGroupIds(channelId) {
3311
+ const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
3312
+ return rows.map((r) => r.group_id);
2359
3313
  }
2360
3314
  /** @internal test-only */
2361
3315
  _rawMainForTest(identityId, channelId) {
@@ -2519,6 +3473,80 @@ var TokenBucketFloor = class {
2519
3473
  }
2520
3474
  };
2521
3475
 
3476
+ // src/decrypt-error-log-limiter.ts
3477
+ var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
3478
+ var MAX_DISTINCT_PER_WINDOW = 4;
3479
+ function normalizeMessage(m) {
3480
+ return m.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>").replace(/[0-9a-f]{16,}/gi, "<hex>");
3481
+ }
3482
+ var DecryptErrorLogLimiter = class {
3483
+ windowMs;
3484
+ now;
3485
+ log;
3486
+ windows = /* @__PURE__ */ new Map();
3487
+ constructor(opts = {}) {
3488
+ this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
3489
+ this.now = opts.now ?? Date.now;
3490
+ this.log = opts.log ?? ((line) => console.warn(line));
3491
+ }
3492
+ /**
3493
+ * Record one decrypt error for `channelId`. Logs immediately if this is
3494
+ * the channel's first error since its window opened (or its prior window
3495
+ * had already elapsed, in which case a summary for that just-closed
3496
+ * window is flushed first); otherwise the error is counted silently
3497
+ * toward the window's eventual summary line.
3498
+ */
3499
+ record(channelId, info) {
3500
+ const t = this.now();
3501
+ const w = this.windows.get(channelId);
3502
+ if (!w) {
3503
+ this.logFirst(channelId, info);
3504
+ this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
3505
+ return;
3506
+ }
3507
+ if (t - w.windowStart >= this.windowMs) {
3508
+ this.flushSummary(channelId, w);
3509
+ this.logFirst(channelId, info);
3510
+ this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
3511
+ return;
3512
+ }
3513
+ w.count += 1;
3514
+ w.lastEpoch = info.epoch;
3515
+ w.lastMessage = info.message;
3516
+ const key = normalizeMessage(info.message);
3517
+ if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
3518
+ this.logFirst(channelId, info);
3519
+ w.loggedKeys.add(key);
3520
+ }
3521
+ }
3522
+ /**
3523
+ * Clear a channel's window (e.g. once the caller learns the channel
3524
+ * cleared/healed) so its NEXT error logs immediately as a fresh "first"
3525
+ * rather than being folded into a stale window's summary. There is
3526
+ * currently no onDecryptSuccess-style hook from the SDK to wire this to
3527
+ * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
3528
+ * exposed here so a future success signal (or a caller with its own
3529
+ * channel-cleared signal) can reset explicitly. In its absence, the
3530
+ * window simply rolls over on interval elapse, which is an accepted
3531
+ * simplification (see report).
3532
+ */
3533
+ reset(channelId) {
3534
+ this.windows.delete(channelId);
3535
+ }
3536
+ logFirst(channelId, info) {
3537
+ this.log(
3538
+ `[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
3539
+ );
3540
+ }
3541
+ flushSummary(channelId, w) {
3542
+ if (w.count <= 1) return;
3543
+ const seconds = Math.round(this.windowMs / 1e3);
3544
+ this.log(
3545
+ `[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
3546
+ );
3547
+ }
3548
+ };
3549
+
2522
3550
  // src/attachments.ts
2523
3551
  import { basename } from "node:path";
2524
3552
  function sanitizeFilename(name, fallback) {
@@ -2615,16 +3643,40 @@ var InvalidPathError = class extends Error {
2615
3643
  if (cause !== void 0) this.cause = cause;
2616
3644
  }
2617
3645
  };
3646
+ var AgentPausedSendError = class extends Error {
3647
+ constructor(channelId) {
3648
+ super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
3649
+ this.channelId = channelId;
3650
+ this.name = "AgentPausedSendError";
3651
+ }
3652
+ };
3653
+ var UnknownActionRunError = class extends Error {
3654
+ constructor(runId) {
3655
+ super(`unknown or already-settled agent action run: ${runId}`);
3656
+ this.runId = runId;
3657
+ this.name = "UnknownActionRunError";
3658
+ }
3659
+ };
2618
3660
  var BRIDGE_VERSION = "0.0.0";
2619
- function hexToBytes2(h) {
3661
+ function hexToBytes4(h) {
2620
3662
  return new Uint8Array(Buffer.from(h, "hex"));
2621
3663
  }
2622
3664
  var BridgeAgent = class _BridgeAgent {
2623
3665
  constructor(cfg, deps = {}) {
2624
3666
  this.cfg = cfg;
2625
- const seed = hexToBytes2(cfg.signingPrivateKey);
3667
+ const seed = hexToBytes4(cfg.signingPrivateKey);
2626
3668
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
2627
- this.transport = deps.transport ?? new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
3669
+ this.transport = deps.transport ?? new RestTransport({
3670
+ baseUrl: cfg.apiBase,
3671
+ bearerToken: cfg.apiKey,
3672
+ connectIdentityId: cfg.identityId,
3673
+ // P1b: hardened delivery-ping reconnect (verify-before-terminal +
3674
+ // half-open + wake + auth war-cap). On by default for the daemon;
3675
+ // SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
3676
+ hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
3677
+ onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
3678
+ `)
3679
+ });
2628
3680
  this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
2629
3681
  this.store = new SqliteMlsStateStore({
2630
3682
  dbPath: cfg.storePath,
@@ -2639,12 +3691,47 @@ var BridgeAgent = class _BridgeAgent {
2639
3691
  // ADR-0002: count + log agent-side decrypt failures. Without this hook the
2640
3692
  // daemon was 100% blind to a wedged channel (it "looked idle"); the
2641
3693
  // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
3694
+ // P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
3695
+ // rather than a raw console.warn per failure — a wedged channel used to
3696
+ // redeliver the same undecryptable row on every pullDelivery cycle and
3697
+ // produced a 114 MB / 765,000-line daemon log in a single day. The
3698
+ // limiter still logs the first failure immediately and collapses every
3699
+ // repeat within 60s into one summary line; errorStats() below is fed
3700
+ // directly by the provider and is completely unaffected.
2642
3701
  onDecryptError: (row, err) => {
2643
3702
  const cid = row.connect_channel_id ?? "(unmapped)";
2644
- console.warn(
2645
- `[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`
2646
- );
2647
- }
3703
+ this.decryptErrorLogLimiter.record(cid, {
3704
+ epoch: row.epoch,
3705
+ queueId: row.queue_id,
3706
+ message: err instanceof Error ? err.message : String(err)
3707
+ });
3708
+ },
3709
+ // P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
3710
+ // channel's decrypt-failure run crosses quarantineThreshold and its
3711
+ // stuck row has been ack-drained. Log one quarantine line, then
3712
+ // best-effort trigger the SAME held-KP re-Welcome heal used by
3713
+ // ingestOnce's Task-11 branch and selfHealSweep (see
3714
+ // healWedgedChannel below) — never throws back into the SDK caller.
3715
+ onWedged: (channelId) => {
3716
+ try {
3717
+ console.warn(
3718
+ `[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
3719
+ );
3720
+ void this.healWedgedChannel(channelId);
3721
+ } catch {
3722
+ }
3723
+ },
3724
+ // Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
3725
+ // calls this before honoring an owner-authored supersede. It must return
3726
+ // a value ONLY when the structural owner is unambiguous AND a pin exists
3727
+ // for it — never trust a bare in-memory cache without reconfirming
3728
+ // against the persisted pin (see resolvePinnedOwnerHook below).
3729
+ onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId),
3730
+ // P1a self-heal: a verified reset supersede whose Welcome we cannot open
3731
+ // (sealed to a stale published KeyPackage) — ask the committer to re-Welcome
3732
+ // us to the reset group with our CURRENT KeyPackage. Cooldown-guarded, never
3733
+ // throws (mirrors the divergence-heal signal path).
3734
+ onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
2648
3735
  });
2649
3736
  this.fetchImpl = deps.fetchImpl ?? fetch;
2650
3737
  this.latency = deps.latency ?? new LatencyRecorder();
@@ -2653,6 +3740,10 @@ var BridgeAgent = class _BridgeAgent {
2653
3740
  capacity: cfg.rateLimitCapacity ?? 20,
2654
3741
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
2655
3742
  });
3743
+ this.actionClock = deps.actionThrottleNow ?? Date.now;
3744
+ this.rng = deps.rng ?? Math.random;
3745
+ this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
3746
+ this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
2656
3747
  const self = this;
2657
3748
  this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
2658
3749
  const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
@@ -2672,6 +3763,14 @@ var BridgeAgent = class _BridgeAgent {
2672
3763
  return String(j.attachment_id);
2673
3764
  };
2674
3765
  this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
3766
+ this.action = {
3767
+ begin: (channelId) => this.actionBegin(channelId),
3768
+ start: (opts) => this.actionStart(opts),
3769
+ update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
3770
+ finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
3771
+ settle: (runId, opts) => this.actionSettle(runId, opts),
3772
+ wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
3773
+ };
2675
3774
  }
2676
3775
  transport;
2677
3776
  provider;
@@ -2701,6 +3800,15 @@ var BridgeAgent = class _BridgeAgent {
2701
3800
  * per channel on a miss.
2702
3801
  */
2703
3802
  channelRoster = /* @__PURE__ */ new Map();
3803
+ /**
3804
+ * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
3805
+ * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
3806
+ * the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
3807
+ * means the DM roster shape was not exactly one non-self, non-agent
3808
+ * counterpart, OR the freshly-fetched key no longer matches an existing pin —
3809
+ * either way the hook must fail closed (return null) for that channel.
3810
+ */
3811
+ channelOwner = /* @__PURE__ */ new Map();
2704
3812
  buffer = [];
2705
3813
  maxBuffer;
2706
3814
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
@@ -2715,6 +3823,13 @@ var BridgeAgent = class _BridgeAgent {
2715
3823
  rateLimitedSends = 0;
2716
3824
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
2717
3825
  welcomeRequestsSent = 0;
3826
+ /** 2026-07-03 latency triage: cumulative delivery pings received on the WS
3827
+ * stream. 0 while messages still flow = the stream is dead and inbound is
3828
+ * riding the 25s safety floor (surfaced on health()/metrics). */
3829
+ pingsReceived = 0;
3830
+ /** True once close() begins — gates the ping-stream-death stderr warning so
3831
+ * a normal shutdown doesn't cry wolf. */
3832
+ closingDown = false;
2718
3833
  /**
2719
3834
  * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
2720
3835
  * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
@@ -2725,7 +3840,94 @@ var BridgeAgent = class _BridgeAgent {
2725
3840
  lastWelcomeRequest = /* @__PURE__ */ new Map();
2726
3841
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
2727
3842
  static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
3843
+ // H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
3844
+ static GRACEFUL_DRAIN_MS = 5e3;
3845
+ /**
3846
+ * P0b (daemon-stability): collapses the onDecryptError console.warn from
3847
+ * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
3848
+ * most one line immediately + one summary line per 60s per channel. LOG
3849
+ * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
3850
+ * live entirely in the SDK provider and are unaffected by this class.
3851
+ */
3852
+ decryptErrorLogLimiter = new DecryptErrorLogLimiter();
3853
+ /**
3854
+ * Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
3855
+ * only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
3856
+ * rows — an idle-but-wiped channel therefore loses its NEXT message (the
3857
+ * very message that would have proven it diverged). selfHealSweep() closes
3858
+ * that gap by proactively walking every non-DM member channel at boot and
3859
+ * on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
3860
+ * back to the delivery-row-driven Task 11 behaviour only.
3861
+ */
3862
+ selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
3863
+ /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
3864
+ static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
3865
+ // C3: add up to this much random jitter to BOTH the boot sweep delay and each
3866
+ // periodic interval, so a fleet of daemons restarting/sweeping together does not
3867
+ // fire re-Welcome signals at the SAME instant and thundering-herd the single
3868
+ // committer (the observed 409 commit-lease saturation). Decorrelation only —
3869
+ // C1 already removed the restart KP-rotation storm that caused the herd.
3870
+ static SELF_HEAL_SWEEP_JITTER_MS = 3e4;
3871
+ selfHealTimer;
3872
+ bootSweepTimer;
3873
+ rng;
2728
3874
  rateFloor;
3875
+ /**
3876
+ * agent.action.* run state, keyed by runId.
3877
+ *
3878
+ * Review M3 (bug fix): the ORIGINAL comment here said "deliberately
3879
+ * unbounded" — but nothing ever bounded it either. Only actionSettle()
3880
+ * deleted an entry; a begun-but-never-settled run (agent crash, bug,
3881
+ * dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
3882
+ * bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
3883
+ * lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
3884
+ * mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
3885
+ */
3886
+ actionRuns = /* @__PURE__ */ new Map();
3887
+ /** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
3888
+ * evicts the oldest (LRU by startedAt) to make room — never grows past
3889
+ * this regardless of how many runs are begun and abandoned. */
3890
+ static MAX_UNSETTLED_ACTION_RUNS = 50;
3891
+ /** Review M3: an unsettled run older than this (by startedAt, relative to
3892
+ * actionClock()) is swept away on the next actionBegin(), independent of
3893
+ * the count-based cap — bounds memory even when begin() calls are rare. */
3894
+ static ACTION_RUN_STALE_MS = 60 * 6e4;
3895
+ // 60 minutes
3896
+ /** Review I3/M3: injected clock (defaults Date.now) for the action-partial
3897
+ * token buckets AND the stale-run sweep — a single seam so tests can
3898
+ * simulate elapsed time deterministically. */
3899
+ actionClock;
3900
+ /**
3901
+ * Per-run token bucket for `agent_action` PARTIAL frames (Global
3902
+ * Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
3903
+ * `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
3904
+ * capacity" — partials instead COALESCE: a throttled call just leaves its
3905
+ * change in `dirty` for the next allowed send to pick up).
3906
+ *
3907
+ * Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
3908
+ * backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
3909
+ * the settled frame + the agent's real reply + reportStatus all compete
3910
+ * for that one shared per-sender budget, so a long run's settled frame or
3911
+ * reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
3912
+ * partials alone use at most ~half the identity's send budget, leaving
3913
+ * headroom for the settled frame / reply / status traffic riding the SAME
3914
+ * cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
3915
+ * `settled` frames bypass this bucket entirely — they always send, exactly
3916
+ * once, per run.
3917
+ */
3918
+ actionRateFloor;
3919
+ /**
3920
+ * Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
3921
+ * the per-run bucket above. Without this, N concurrent runs each get their
3922
+ * OWN ~30/min budget, so the partial send rate multiplies with agent
3923
+ * concurrency and can still blow past the shared 60/min identity edge cap
3924
+ * (each run individually "has headroom" but the SUM doesn't). Same
3925
+ * capacity/refill as the per-run bucket — it caps the aggregate, not any
3926
+ * single run.
3927
+ */
3928
+ globalActionRateFloor;
3929
+ /** Public `agent.action.*` surface — assigned once at the end of the constructor. */
3930
+ action;
2729
3931
  waiters = [];
2730
3932
  pingHandle;
2731
3933
  safetyTimer;
@@ -2762,6 +3964,14 @@ var BridgeAgent = class _BridgeAgent {
2762
3964
  this.startedAt = Date.now();
2763
3965
  await this.provider.publishKeyPackage();
2764
3966
  this.startReceivePump();
3967
+ if (this.selfHealSweepEnabled) {
3968
+ this.bootSweepTimer = setTimeout(() => void this.selfHealSweep(), this.sweepJitter());
3969
+ this.bootSweepTimer.unref?.();
3970
+ }
3971
+ }
3972
+ /** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
3973
+ sweepJitter() {
3974
+ return Math.floor(this.rng() * _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS);
2765
3975
  }
2766
3976
  async reconcileJoin(channelId) {
2767
3977
  if (this.joinState.get(channelId) === "JOINED") return true;
@@ -2774,6 +3984,145 @@ var BridgeAgent = class _BridgeAgent {
2774
3984
  return false;
2775
3985
  }
2776
3986
  }
3987
+ /**
3988
+ * The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
3989
+ * reactive no-local-state branch in ingestOnce(), the proactive
3990
+ * selfHealSweep() (Task 12), the onWedged forked-state recovery
3991
+ * (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
3992
+ *
3993
+ * Before asking the committer to (re-)Welcome us, it RECONCILES the server's
3994
+ * published KeyPackage to our held bundle. This is the fix for the observed
3995
+ * "fresh DM never replies" AND "reset doesn't work": the committer seals every
3996
+ * Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
3997
+ * locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
3998
+ * "private KeyPackage key no longer held" and the Welcome is discarded. A bare
3999
+ * re-Welcome re-seals to the SAME diverged published KP — un-openable again —
4000
+ * looping until the channel quarantines. So:
4001
+ * - published == held → publishKeyPackage() (no-op while held; mints only if
4002
+ * somehow none is held), then re-Welcome re-seals to the current KP we hold.
4003
+ * - published != held → forceRepublishKeyPackage() re-syncs the server to a
4004
+ * bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
4005
+ * diverged published KP means every other pending Welcome sealed via the
4006
+ * server is un-openable too, so replacing the shared per-device bundle
4007
+ * strands nothing that was openable (see heldBundleMatchesPublishedKp).
4008
+ * getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
4009
+ * Callers own their cooldown/rate-limit gate and try/catch — this does neither.
4010
+ */
4011
+ async signalHeldKpWelcome(groupId) {
4012
+ if (await this.provider.heldBundleMatchesPublishedKp()) {
4013
+ await this.provider.publishKeyPackage();
4014
+ } else {
4015
+ await this.provider.forceRepublishKeyPackage();
4016
+ }
4017
+ const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
4018
+ await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
4019
+ this.welcomeRequestsSent++;
4020
+ }
4021
+ /**
4022
+ * P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
4023
+ * `connect_channel_id` — resolve it to an MLS groupId the SAME way
4024
+ * selfHealSweep does, via the read-only getConnectChannelGroup probe
4025
+ * (never initConnectChannelGroup, which side-effects a member_device_ids
4026
+ * union + commit), then fire the existing held-KP re-Welcome signal.
4027
+ * Respects the SAME per-channel cooldown (lastWelcomeRequest /
4028
+ * REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
4029
+ * selfHealSweep — this must never hammer requestWelcome on its own.
4030
+ *
4031
+ * Best-effort in every sense: if the channel has no resolvable group (no
4032
+ * local state to probe from), this logs nothing further and just returns —
4033
+ * the channel is left to the next selfHealSweep pass or reactive Task-11
4034
+ * branch, mirroring how those two paths already treat an unresolvable
4035
+ * group (`continue`, no signal). ANY failure along the way (probe throws,
4036
+ * signal throws) is swallowed: a wedge heal must never throw back into the
4037
+ * SDK's onWedged caller. Single-shot per wedge episode — full rejoin
4038
+ * reliability is P1, not this hook's job.
4039
+ */
4040
+ async healWedgedChannel(channelId) {
4041
+ try {
4042
+ const now = performance.now();
4043
+ const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4044
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
4045
+ const g = await this.backend.getConnectChannelGroup(channelId);
4046
+ if (!g?.group_id) return;
4047
+ this.lastWelcomeRequest.set(channelId, now);
4048
+ if (await this.provider.hasPersistedChannelState(channelId)) {
4049
+ await this.provider.resetChannelForRejoin(channelId);
4050
+ this.joinState.delete(channelId);
4051
+ }
4052
+ await this.signalHeldKpWelcome(g.group_id);
4053
+ } catch {
4054
+ }
4055
+ }
4056
+ /**
4057
+ * P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
4058
+ * Welcome we cannot open — it was sealed to a stale published KeyPackage our
4059
+ * held bundle no longer matches ("No matching secret found"). trySupersede has
4060
+ * verified owner + attestation + signature, so this is a real reset we WANT; we
4061
+ * just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
4062
+ * `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
4063
+ * a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
4064
+ * and the reset converges. The groupId is known (handed in), so no probe needed.
4065
+ *
4066
+ * Returns true ONLY when a re-Welcome was actually requested — trySupersede then
4067
+ * discards the un-openable row. Under the shared per-channel cooldown (or on any
4068
+ * failure) returns false, so trySupersede LEAVES the row for a bounded retry
4069
+ * rather than dropping it with no replacement in flight. Never throws.
4070
+ */
4071
+ async healSupersedeUnopenable(channelId, groupId) {
4072
+ try {
4073
+ const now = performance.now();
4074
+ const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4075
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return false;
4076
+ this.lastWelcomeRequest.set(channelId, now);
4077
+ await this.signalHeldKpWelcome(groupId);
4078
+ return true;
4079
+ } catch {
4080
+ return false;
4081
+ }
4082
+ }
4083
+ /**
4084
+ * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
4085
+ * branch only fires reactively — off a delivery ROW that already arrived
4086
+ * for a diverged channel. A wiped-but-IDLE channel (no traffic since the
4087
+ * wipe) never produces that row, so its next real message would be the
4088
+ * first thing lost. This sweep walks every member channel — rooms AND DMs
4089
+ * (H2: a wiped, idle DM with no traffic since the wipe otherwise never
4090
+ * proactively re-Welcomes, so it stays dark until the owner happens to send)
4091
+ * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
4092
+ * persisted state even after a reconcile attempt, resolves group_id via
4093
+ * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
4094
+ * which would side-effect a member_device_ids union + commit) and applies
4095
+ * the same held-KP signal — respecting the existing per-channel cooldown.
4096
+ *
4097
+ * Each channel is wrapped in its own try/catch: `listChannels` filters at
4098
+ * the identity level while `requestWelcome` (via request_welcome) gates at
4099
+ * the device level, so a channel where this identity is a member but this
4100
+ * DEVICE never held a leaf can 403 on the signal call. That's harmless and
4101
+ * must not abort the sweep for every other channel.
4102
+ */
4103
+ async selfHealSweep() {
4104
+ let chans;
4105
+ try {
4106
+ chans = await this.listChannels();
4107
+ } catch {
4108
+ return;
4109
+ }
4110
+ for (const c of chans) {
4111
+ try {
4112
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4113
+ await this.reconcileJoin(c.channel_id);
4114
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4115
+ const g = await this.backend.getConnectChannelGroup(c.channel_id);
4116
+ if (!g?.group_id) continue;
4117
+ const now = performance.now();
4118
+ const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
4119
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
4120
+ this.lastWelcomeRequest.set(c.channel_id, now);
4121
+ await this.signalHeldKpWelcome(g.group_id);
4122
+ } catch {
4123
+ }
4124
+ }
4125
+ }
2777
4126
  /**
2778
4127
  * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
2779
4128
  * trip (incrementing the observable counter). `kind` namespaces the bucket so
@@ -2786,6 +4135,40 @@ var BridgeAgent = class _BridgeAgent {
2786
4135
  throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
2787
4136
  }
2788
4137
  }
4138
+ /**
4139
+ * The ONE seam every application-send path (send/sendA2A/sendAttachment)
4140
+ * calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
4141
+ * daemon's per-room-pause terminal classification (Task 9).
4142
+ *
4143
+ * `AgentPausedError` (Task 8's SDK classification of the backend's 403
4144
+ * `agent_paused`) means an owner/admin has deliberately muted this agent in
4145
+ * this room — NOT a transient/network failure. That distinction MUST be
4146
+ * made here, before control returns to any caller, so:
4147
+ * - it never reaches a heal/self-heal trigger (healWedgedChannel /
4148
+ * onWedged re-Welcome are wired off the RECEIVE path via SDK provider
4149
+ * callbacks, not off a send failure — this seam guarantees a send
4150
+ * failure never grows one either, now or as the code evolves), and
4151
+ * - it never gets treated as a generic/retryable failure by a caller —
4152
+ * rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
4153
+ * ChannelPendingError/RateLimitedError, both also bridge-agent.ts
4154
+ * locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
4155
+ * same way they already do for those two, instead of it falling into
4156
+ * their generic "internal error" bucket.
4157
+ * Any other error (including StaleEpochError retries, which sendWithSelfHeal
4158
+ * already resolves internally) propagates unchanged.
4159
+ */
4160
+ async sendWithSelfHealTerminal(...args2) {
4161
+ const [, channelId] = args2;
4162
+ try {
4163
+ return await sendWithSelfHeal(...args2);
4164
+ } catch (e) {
4165
+ if (e instanceof AgentPausedError) {
4166
+ console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
4167
+ throw new AgentPausedSendError(channelId);
4168
+ }
4169
+ throw e;
4170
+ }
4171
+ }
2789
4172
  async send(channelId, text, messageId) {
2790
4173
  const meta = this.freshMeta(channelId);
2791
4174
  if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
@@ -2793,7 +4176,7 @@ var BridgeAgent = class _BridgeAgent {
2793
4176
  if (!joined) throw new ChannelPendingError(channelId);
2794
4177
  const id = messageId ?? crypto.randomUUID();
2795
4178
  const sendStart = performance.now();
2796
- await sendWithSelfHeal(
4179
+ await this.sendWithSelfHealTerminal(
2797
4180
  this.provider,
2798
4181
  channelId,
2799
4182
  (epoch) => this.latency.time(
@@ -2829,7 +4212,7 @@ var BridgeAgent = class _BridgeAgent {
2829
4212
  if (!joined) throw new ChannelPendingError(channelId);
2830
4213
  const id = messageId ?? crypto.randomUUID();
2831
4214
  const sendStart = performance.now();
2832
- await sendWithSelfHeal(
4215
+ await this.sendWithSelfHealTerminal(
2833
4216
  this.provider,
2834
4217
  channelId,
2835
4218
  (epoch) => this.latency.time(
@@ -2882,7 +4265,7 @@ var BridgeAgent = class _BridgeAgent {
2882
4265
  }
2883
4266
  const id = crypto.randomUUID();
2884
4267
  const sendStart = performance.now();
2885
- await sendWithSelfHeal(
4268
+ await this.sendWithSelfHealTerminal(
2886
4269
  this.provider,
2887
4270
  channelId,
2888
4271
  (epoch) => this.latency.time(
@@ -2979,9 +4362,77 @@ var BridgeAgent = class _BridgeAgent {
2979
4362
  members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
2980
4363
  }
2981
4364
  this.channelRoster.set(channelId, { members, at: Date.now() });
4365
+ await this.resolveAndPinOwner(channelId, rows);
2982
4366
  } catch {
2983
4367
  }
2984
4368
  }
4369
+ /**
4370
+ * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
4371
+ * NEVER read the server's `role` field to pick the owner. In a DM the owner
4372
+ * is the SINGLE non-self, non-agent counterpart; any other roster shape
4373
+ * (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
4374
+ * supersede eligibility). On the first clean sighting of an unambiguous
4375
+ * owner with no existing pin, TOFU-pin its public key. On a later sighting
4376
+ * where a pin already exists and the freshly-fetched key differs, this is an
4377
+ * anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
4378
+ * this must never throw into the roster refresh path.
4379
+ */
4380
+ async resolveAndPinOwner(channelId, rows) {
4381
+ try {
4382
+ const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
4383
+ if (counterparts.length !== 1) {
4384
+ this.channelOwner.set(channelId, "anomalous");
4385
+ return;
4386
+ }
4387
+ const owner = counterparts[0];
4388
+ const publicKeyHex = owner.public_key ?? "";
4389
+ if (!publicKeyHex) {
4390
+ this.channelOwner.set(channelId, "anomalous");
4391
+ return;
4392
+ }
4393
+ this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
4394
+ const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
4395
+ if (!existing) {
4396
+ await this.store.savePinnedIdentityKey(owner.identity_id, {
4397
+ publicKeyHex,
4398
+ keyAlgo: owner.key_algo ?? "ed25519"
4399
+ });
4400
+ } else if (existing.publicKeyHex !== publicKeyHex) {
4401
+ console.warn(
4402
+ `[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
4403
+ );
4404
+ this.channelOwner.set(channelId, "anomalous");
4405
+ }
4406
+ } catch {
4407
+ }
4408
+ }
4409
+ /**
4410
+ * Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
4411
+ * SDK's step-0 guard calls this before honoring an owner-authored supersede;
4412
+ * returning null makes it fail closed. Returns a value ONLY when (a) the
4413
+ * structural owner cache has an unambiguous entry for this channel AND (b) a
4414
+ * persisted pin still exists and matches it — a bare in-memory cache hit is
4415
+ * never trusted on its own, it is always reconfirmed against the store.
4416
+ */
4417
+ async resolvePinnedOwnerHook(channelId) {
4418
+ const cached = this.channelOwner.get(channelId);
4419
+ const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
4420
+ if (!cached || cached === "anomalous") {
4421
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
4422
+ return null;
4423
+ }
4424
+ const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
4425
+ if (!pin) {
4426
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: NO PERSISTED PIN for owner ${cached.identityId.slice(0, 8)} (cache had ${cached.publicKeyHex.slice(0, 8)})`);
4427
+ return null;
4428
+ }
4429
+ if (pin.publicKeyHex !== cached.publicKeyHex) {
4430
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: PIN MISMATCH pin=${pin.publicKeyHex.slice(0, 8)} cache=${cached.publicKeyHex.slice(0, 8)}`);
4431
+ return null;
4432
+ }
4433
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
4434
+ return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
4435
+ }
2985
4436
  async reportStatus(channelId, state, detail) {
2986
4437
  const joined = await this.reconcileJoin(channelId);
2987
4438
  if (!joined) throw new ChannelPendingError(channelId);
@@ -3016,20 +4467,213 @@ var BridgeAgent = class _BridgeAgent {
3016
4467
  (envelope) => this.transport.publish(envelope)
3017
4468
  );
3018
4469
  }
4470
+ /** `runId` must exist and not have settled yet — settle() is terminal. */
4471
+ requireActionRun(runId) {
4472
+ const run = this.actionRuns.get(runId);
4473
+ if (!run || run.settled) throw new UnknownActionRunError(runId);
4474
+ return run;
4475
+ }
4476
+ /**
4477
+ * Build + send one `agent_action` E2E body over `system_event` (mirrors
4478
+ * sendA2A/reportStatus's envelope construction — same self-heal seam). Used
4479
+ * for BOTH partial and settled frames; the caller decides whether a send is
4480
+ * throttle-gated (partials, via flushActionPartial) or unconditional
4481
+ * (settled, always exactly once).
4482
+ */
4483
+ async sendActionFrame(channelId, body) {
4484
+ const joined = await this.reconcileJoin(channelId);
4485
+ if (!joined) throw new ChannelPendingError(channelId);
4486
+ await sendWithSelfHeal(
4487
+ this.provider,
4488
+ channelId,
4489
+ (epoch) => buildOutboundEnvelope({
4490
+ custody: this.custody,
4491
+ groupKey: this.provider,
4492
+ messageId: crypto.randomUUID(),
4493
+ channelId,
4494
+ enclaveId: this.cfg.enclaveId,
4495
+ senderId: this.cfg.identityId,
4496
+ senderType: "agent",
4497
+ type: "system_event",
4498
+ bodyPlaintext: JSON.stringify(body),
4499
+ bindingId: "",
4500
+ bindingVersion: "0.0.0",
4501
+ agentSkillId: null,
4502
+ epoch,
4503
+ traceparent: ZERO_TRACEPARENT,
4504
+ references: [],
4505
+ stage5Outcome: "allow",
4506
+ stage5Evaluators: []
4507
+ }),
4508
+ (envelope) => this.transport.publish(envelope)
4509
+ );
4510
+ }
4511
+ /**
4512
+ * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
4513
+ * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
4514
+ * no-op too — the change stays in `dirty` and rides along with whatever the
4515
+ * NEXT allowed send captures (a later start/update/finish call, or settle(),
4516
+ * which always flushes regardless of the bucket). This is the coalescing:
4517
+ * a burst of rapid calls collapses into however many sends the ~1/sec floor
4518
+ * actually allows, each carrying every node changed since the last send.
4519
+ */
4520
+ async flushActionPartial(runId, run) {
4521
+ if (run.dirty.size === 0) return;
4522
+ if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
4523
+ if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
4524
+ const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
4525
+ run.dirty.clear();
4526
+ await this.sendActionFrame(run.channelId, {
4527
+ v: 1,
4528
+ type: "agent_action",
4529
+ agentActionVersion: "1",
4530
+ phase: "partial",
4531
+ runId,
4532
+ nodes
4533
+ });
4534
+ }
4535
+ /** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
4536
+ * (relative to `now`). Lazy — invoked on every actionBegin() rather than
4537
+ * a background timer, mirroring the client store's orphan-TTL pattern. */
4538
+ sweepStaleActionRuns(now) {
4539
+ for (const [runId, run] of this.actionRuns) {
4540
+ if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
4541
+ }
4542
+ }
4543
+ actionBegin(channelId) {
4544
+ const now = this.actionClock();
4545
+ this.sweepStaleActionRuns(now);
4546
+ if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
4547
+ let oldestId;
4548
+ let oldestAt = Infinity;
4549
+ for (const [id, r] of this.actionRuns) {
4550
+ if (r.startedAt < oldestAt) {
4551
+ oldestAt = r.startedAt;
4552
+ oldestId = id;
4553
+ }
4554
+ }
4555
+ if (oldestId !== void 0) this.actionRuns.delete(oldestId);
4556
+ }
4557
+ const runId = crypto.randomUUID();
4558
+ this.actionRuns.set(runId, {
4559
+ channelId,
4560
+ nodes: /* @__PURE__ */ new Map(),
4561
+ dirty: /* @__PURE__ */ new Set(),
4562
+ startedAt: now,
4563
+ settled: false
4564
+ });
4565
+ return runId;
4566
+ }
4567
+ async actionStart(opts) {
4568
+ const run = this.requireActionRun(opts.runId);
4569
+ const nodeId = crypto.randomUUID();
4570
+ const node = {
4571
+ id: nodeId,
4572
+ type: opts.type,
4573
+ title: opts.title,
4574
+ status: "running",
4575
+ ts: Date.now(),
4576
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
4577
+ };
4578
+ run.nodes.set(nodeId, node);
4579
+ run.dirty.add(nodeId);
4580
+ await this.flushActionPartial(opts.runId, run);
4581
+ return nodeId;
4582
+ }
4583
+ async actionUpdate(runId, nodeId, patch) {
4584
+ const run = this.requireActionRun(runId);
4585
+ const existing = run.nodes.get(nodeId);
4586
+ if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
4587
+ const updated = {
4588
+ ...existing,
4589
+ ...patch.status !== void 0 ? { status: patch.status } : {},
4590
+ ...patch.detail !== void 0 ? { detail: patch.detail } : {},
4591
+ ts: Date.now()
4592
+ };
4593
+ run.nodes.set(nodeId, updated);
4594
+ run.dirty.add(nodeId);
4595
+ await this.flushActionPartial(runId, run);
4596
+ }
4597
+ async actionFinish(runId, nodeId, patch) {
4598
+ await this.actionUpdate(runId, nodeId, patch);
4599
+ }
4600
+ async actionSettle(runId, opts = {}) {
4601
+ const run = this.requireActionRun(runId);
4602
+ run.settled = true;
4603
+ run.dirty.clear();
4604
+ const nodes = [...run.nodes.values()];
4605
+ await this.sendActionFrame(run.channelId, {
4606
+ v: 1,
4607
+ type: "agent_action",
4608
+ agentActionVersion: "1",
4609
+ phase: "settled",
4610
+ runId,
4611
+ startedAt: run.startedAt,
4612
+ endedAt: Date.now(),
4613
+ ...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
4614
+ nodes
4615
+ });
4616
+ this.actionRuns.delete(runId);
4617
+ }
4618
+ /**
4619
+ * Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
4620
+ * invocation so a bare agent gets a start→finish node pair without hand-
4621
+ * rolling the begin/start/update/finish calls itself. Success → status
4622
+ * "done"; a thrown error → status "error" with the message captured in
4623
+ * `detail.error`, then the original error is rethrown (wrap never swallows).
4624
+ */
4625
+ async actionWrap(runId, fn, opts) {
4626
+ const nodeId = await this.actionStart({
4627
+ runId,
4628
+ type: opts.type,
4629
+ title: opts.title,
4630
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
4631
+ });
4632
+ try {
4633
+ const result = await fn();
4634
+ await this.actionFinish(runId, nodeId, { status: "done" });
4635
+ return result;
4636
+ } catch (err) {
4637
+ await this.actionFinish(runId, nodeId, {
4638
+ status: "error",
4639
+ detail: { error: err instanceof Error ? err.message : String(err) }
4640
+ });
4641
+ throw err;
4642
+ }
4643
+ }
3019
4644
  startReceivePump() {
3020
4645
  const sub = this.transport.subscribeDeliveryPings("");
3021
4646
  this.pingHandle = { close: sub.close };
3022
4647
  void (async () => {
3023
4648
  try {
3024
- for await (const _ping of sub.pings) {
4649
+ for await (const ping of sub.pings) {
4650
+ this.pingsReceived++;
4651
+ if (ping?.reconnected) {
4652
+ process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
4653
+ }
3025
4654
  await this.ingestOnce();
3026
4655
  }
3027
4656
  } catch {
3028
4657
  }
4658
+ if (!this.closingDown) {
4659
+ process.stderr.write(
4660
+ "[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
4661
+ );
4662
+ }
3029
4663
  })();
3030
4664
  this.safetyTimer = setInterval(() => {
3031
4665
  void this.ingestOnce();
3032
4666
  }, 25e3);
4667
+ if (this.selfHealSweepEnabled) {
4668
+ const scheduleSweep = () => {
4669
+ this.selfHealTimer = setTimeout(() => {
4670
+ void this.selfHealSweep();
4671
+ scheduleSweep();
4672
+ }, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
4673
+ this.selfHealTimer.unref?.();
4674
+ };
4675
+ scheduleSweep();
4676
+ }
3033
4677
  }
3034
4678
  async ingestOnce() {
3035
4679
  const cycleStart = performance.now();
@@ -3044,6 +4688,11 @@ var BridgeAgent = class _BridgeAgent {
3044
4688
  await this.reconcileJoin(cid);
3045
4689
  }
3046
4690
  }
4691
+ for (const cid of channelIds) {
4692
+ if (this.freshRoster(cid) === void 0) {
4693
+ await this.refreshChannelRoster(cid);
4694
+ }
4695
+ }
3047
4696
  for (const row of rows) {
3048
4697
  const cid = row.connect_channel_id;
3049
4698
  const gid = row.group_id;
@@ -3054,8 +4703,7 @@ var BridgeAgent = class _BridgeAgent {
3054
4703
  if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
3055
4704
  this.lastWelcomeRequest.set(cid, now);
3056
4705
  try {
3057
- await this.backend.requestWelcome(gid, this.cfg.deviceId);
3058
- this.welcomeRequestsSent++;
4706
+ await this.signalHeldKpWelcome(gid);
3059
4707
  } catch {
3060
4708
  }
3061
4709
  }
@@ -3067,6 +4715,9 @@ var BridgeAgent = class _BridgeAgent {
3067
4715
  } catch {
3068
4716
  return;
3069
4717
  }
4718
+ for (const cid of entries.supersededChannelIds ?? []) {
4719
+ this.joinState.set(cid, "JOINED");
4720
+ }
3070
4721
  if (!entries.length) return;
3071
4722
  let metaRefreshed = false;
3072
4723
  if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
@@ -3249,6 +4900,7 @@ var BridgeAgent = class _BridgeAgent {
3249
4900
  rate_limited: this.rateLimitedSends,
3250
4901
  permanently_skipped: this.provider.permanentlySkippedCount(),
3251
4902
  failed_attachments: this.failedAttachments,
4903
+ delivery_pings: this.pingsReceived,
3252
4904
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
3253
4905
  version: BRIDGE_VERSION
3254
4906
  };
@@ -3294,8 +4946,12 @@ var BridgeAgent = class _BridgeAgent {
3294
4946
  };
3295
4947
  }
3296
4948
  async close() {
4949
+ this.closingDown = true;
3297
4950
  this.pingHandle?.close();
3298
4951
  if (this.safetyTimer) clearInterval(this.safetyTimer);
4952
+ if (this.bootSweepTimer) clearTimeout(this.bootSweepTimer);
4953
+ if (this.selfHealTimer) clearTimeout(this.selfHealTimer);
4954
+ await this.provider.drainInFlight(_BridgeAgent.GRACEFUL_DRAIN_MS);
3299
4955
  this.store.close();
3300
4956
  }
3301
4957
  };
@@ -3427,6 +5083,7 @@ var BRIDGE_ERRORS = {
3427
5083
  body_too_large: { http: 400, message: "request body exceeds the size limit" },
3428
5084
  channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
3429
5085
  rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
5086
+ unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
3430
5087
  internal: { http: 500, message: "internal error" }
3431
5088
  };
3432
5089
  function errorBody(code, detail) {
@@ -3836,6 +5493,79 @@ async function handleInbound(sock, agent, data, inflight) {
3836
5493
  }
3837
5494
  return;
3838
5495
  }
5496
+ const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
5497
+ const NODE_STATUSES = ["pending", "running", "done", "error"];
5498
+ const actionError = (e) => {
5499
+ sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
5500
+ };
5501
+ if (m.type === "action_begin") {
5502
+ if (typeof m.channel_id !== "string" || !m.channel_id) {
5503
+ sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
5504
+ return;
5505
+ }
5506
+ try {
5507
+ sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
5508
+ } catch (e) {
5509
+ actionError(e);
5510
+ }
5511
+ return;
5512
+ }
5513
+ if (m.type === "action_start") {
5514
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
5515
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
5516
+ return;
5517
+ }
5518
+ try {
5519
+ const nodeId = await agent.action.start({
5520
+ runId: m.run_id,
5521
+ type: m.node_type,
5522
+ title: m.title,
5523
+ parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
5524
+ });
5525
+ sendJson(sock, { type: "action_started", node_id: nodeId });
5526
+ } catch (e) {
5527
+ actionError(e);
5528
+ }
5529
+ return;
5530
+ }
5531
+ if (m.type === "action_update" || m.type === "action_finish") {
5532
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
5533
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
5534
+ return;
5535
+ }
5536
+ try {
5537
+ if (m.type === "action_finish") {
5538
+ if (m.status !== "done" && m.status !== "error") {
5539
+ sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
5540
+ return;
5541
+ }
5542
+ await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
5543
+ } else {
5544
+ if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
5545
+ sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
5546
+ return;
5547
+ }
5548
+ await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
5549
+ }
5550
+ sendJson(sock, { type: "ok" });
5551
+ } catch (e) {
5552
+ actionError(e);
5553
+ }
5554
+ return;
5555
+ }
5556
+ if (m.type === "action_settle") {
5557
+ if (typeof m.run_id !== "string" || !m.run_id) {
5558
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
5559
+ return;
5560
+ }
5561
+ try {
5562
+ await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
5563
+ sendJson(sock, { type: "action_settled", run_id: m.run_id });
5564
+ } catch (e) {
5565
+ actionError(e);
5566
+ }
5567
+ return;
5568
+ }
3839
5569
  sendJson(sock, wsErrorFrame("invalid_request"));
3840
5570
  }
3841
5571
  export {