@securecoms/agent-bridge 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -973,6 +973,21 @@ copied after the owner's next typed message.
973
973
 
974
974
  ---
975
975
 
976
+ ## Release notes: 0.37.0
977
+
978
+ <!-- [COPY, approved by Eric 2026-11-01 ("Notes ok"), connected apps removal confirmation] -->
979
+
980
+ #### What's new
981
+ - **Connected apps are ready on the agent side.** When you remove a connected app, your agent now confirms that it deleted the app's sign-in. SecureComs shows the removal as done only after your agent confirms it.
982
+ - The agent sends this confirmation only after a message in your private chat with it. Like the tool status summary, it gives the time only to the 15-minute period. Only you can see it.
983
+ - You still cannot turn connected apps on. That comes in a later SecureComs update.
984
+
985
+ #### Upgrade
986
+
987
+ Upgrade the bridge and the runtime together, then restart your agent. Connected apps need @securecoms/agent-runtime 0.17.0.
988
+
989
+ If you go back to 0.36.0, your agent keeps working.
990
+
976
991
  ## Release notes: 0.36.0
977
992
 
978
993
  <!-- [COPY, approved by Eric 2026-10-31 ("notes OK, ship now"), tool status + connected apps groundwork] -->
package/dist/bin.js CHANGED
@@ -7555,7 +7555,7 @@ __export(import_bundle_exports, {
7555
7555
  ModelImportError: () => ModelImportError,
7556
7556
  importModelBundle: () => importModelBundle
7557
7557
  });
7558
- import { createHash as createHash6, randomBytes as randomBytes13 } from "node:crypto";
7558
+ import { createHash as createHash7, randomBytes as randomBytes13 } from "node:crypto";
7559
7559
  import { readFileSync as readFileSync16, statSync as statSync7, writeFileSync as writeFileSync4 } from "node:fs";
7560
7560
  import { join as join25 } from "node:path";
7561
7561
  async function importModelBundle(srcDir, root3, m) {
@@ -7578,7 +7578,7 @@ async function importModelBundle(srcDir, root3, m) {
7578
7578
  } catch {
7579
7579
  throw new ModelImportError(`${f.name} is missing from the import directory`);
7580
7580
  }
7581
- if (bytes.length !== f.size || createHash6("sha256").update(bytes).digest("hex") !== f.sha256) throw mismatch;
7581
+ if (bytes.length !== f.size || createHash7("sha256").update(bytes).digest("hex") !== f.sha256) throw mismatch;
7582
7582
  verified.push({ f, bytes });
7583
7583
  }
7584
7584
  const imported = [];
@@ -30465,6 +30465,7 @@ function checkGroupLink(v) {
30465
30465
  }
30466
30466
 
30467
30467
  // src/bridge-agent.ts
30468
+ import { createHash as createHash6 } from "node:crypto";
30468
30469
  import { AsyncLocalStorage } from "node:async_hooks";
30469
30470
 
30470
30471
  // src/reply-intent.ts
@@ -36982,6 +36983,8 @@ function ownerDmRelayOf(leafClass, leaves, ids) {
36982
36983
 
36983
36984
  // src/grants/mcp-policy.ts
36984
36985
  var MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36986
+ var MCP_DELETION_ACK_FEATURE = "mcp_deletion_ack_v1";
36987
+ var MCP_ACK_STATES = /* @__PURE__ */ new Set(["ok", "not_entitled", "kill_switch"]);
36985
36988
  function mcpEntitlementReading(mcpEntitled, mcpCause) {
36986
36989
  if (mcpEntitled === true && mcpCause === "entitled") return "entitled";
36987
36990
  if (mcpEntitled === false && mcpCause === "not_entitled") return "not_entitled";
@@ -38289,6 +38292,29 @@ var BridgeAgent = class _BridgeAgent {
38289
38292
  */
38290
38293
  mcpPolicyWritten = null;
38291
38294
  mcpPolicyWrittenJson = null;
38295
+ /** Deletion-ack ruling §3: the manifest counter in force when `mcpPolicyWrittenJson` was written (never read live). */
38296
+ mcpPolicyWrittenCounter = null;
38297
+ /** Ruling §3: newest counter whose written policy the runtime fully acted on. Memory only, monotonic, starts at 0. */
38298
+ mcpDeletionsThrough = 0;
38299
+ /** Ruling §3: the last `credentials_present` an accepted frame carried; null until one arrives. */
38300
+ mcpCredentialsPresentValue = null;
38301
+ /** Ruling §3: per attached socket, whether its latest hello declared mcp_deletion_ack_v1 (harness noted false). */
38302
+ mcpDeletionAckSockets = /* @__PURE__ */ new Map();
38303
+ /** Ruling §3 rate: the ack-derived report values in the last report posted (null until one is). */
38304
+ lastReportedMcpAck = null;
38305
+ /** #1351 round 2 M-1: bumped on every byte-changing policy write (and on the inert document being put in force). */
38306
+ policyWriteSeq = 0;
38307
+ /** M-1: per ack-capable socket, the policyWriteSeq at the dispatch of each owner-DM turn, by message id (bounded). */
38308
+ mcpTurnSeq = /* @__PURE__ */ new Map();
38309
+ /**
38310
+ * #1351 round 2 H-1: the ack values the REPORT may carry. An ack lands seconds after an owner-DM turn and a report
38311
+ * posted then would time the turn (record_report sets applied_at). So, as the tool-status sender: `open` holds a
38312
+ * change while its 15-minute UTC bucket is open, `closed` what the report carries, and the only post is from one
38313
+ * timer at the next bucket boundary plus jitter.
38314
+ */
38315
+ mcpAckOpen = null;
38316
+ mcpAckClosed = { through: 0, credentials: null };
38317
+ mcpAckTimer = null;
38292
38318
  /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
38293
38319
  mcpPolicyRetryDelaysMs;
38294
38320
  mcpPolicyRetryTimer = null;
@@ -38933,15 +38959,22 @@ var BridgeAgent = class _BridgeAgent {
38933
38959
  recomputeMcpPolicy() {
38934
38960
  const doc = mcpPolicyFor(this.mcpPolicyInput());
38935
38961
  const json2 = JSON.stringify(doc);
38936
- if (json2 === this.mcpPolicyWrittenJson) return;
38962
+ if (json2 === this.mcpPolicyWrittenJson) {
38963
+ if (this.mcpPolicyWritten !== null && this.mcpPolicyWrittenCounter !== null) this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38964
+ return;
38965
+ }
38937
38966
  if (MCP_INERT_STATES.has(doc.state) && !doc.r3_hold && Object.keys(doc.servers).length === 0 && !existsSync5(join20(runtimeConfigDir(this.home, this.cfg.identityId), MCP_POLICY_FILE))) {
38938
38967
  this.mcpPolicyWritten = doc;
38939
38968
  this.mcpPolicyWrittenJson = json2;
38969
+ this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38970
+ this.policyWriteSeq++;
38940
38971
  this.clearMcpPolicyRetry();
38941
38972
  return;
38942
38973
  }
38943
38974
  this.mcpPolicyWritten = null;
38944
38975
  this.mcpPolicyWrittenJson = null;
38976
+ this.mcpPolicyWrittenCounter = null;
38977
+ this.policyWriteSeq++;
38945
38978
  try {
38946
38979
  this.grantWriters.writeMcpPolicy(this.home, this.cfg.identityId, doc);
38947
38980
  } catch (e) {
@@ -38951,6 +38984,7 @@ var BridgeAgent = class _BridgeAgent {
38951
38984
  }
38952
38985
  this.mcpPolicyWritten = doc;
38953
38986
  this.mcpPolicyWrittenJson = json2;
38987
+ this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38954
38988
  this.clearMcpPolicyRetry();
38955
38989
  }
38956
38990
  /** A document is in force: no pending retry, and the next failure starts the backoff from the first delay. */
@@ -39066,6 +39100,7 @@ var BridgeAgent = class _BridgeAgent {
39066
39100
  const local = readLocalReport(this.home, this.cfg.identityId);
39067
39101
  const reportedSearchProviders = this.searchProvidersCapable();
39068
39102
  const reportedUtilityTools = this.utilityToolsCapable();
39103
+ const reportedMcpAck = this.mcpAckSignature();
39069
39104
  const carriedOwnerDmTurn = this.toolStatusSender.carry();
39070
39105
  const report = {
39071
39106
  applied_version: version,
@@ -39140,6 +39175,9 @@ var BridgeAgent = class _BridgeAgent {
39140
39175
  // MCP Task 19 (spec §12.3): separate from `capability`; content-free enums and pattern tokens only.
39141
39176
  mcp_capability: this.reportedMcpCapability(),
39142
39177
  mcp_servers: this.reportedMcpServers(),
39178
+ // Deletion-ack ruling §5.3: content-free scalars. `mcp_deletions_through` only while EVERY attached runtime socket
39179
+ // declared mcp_deletion_ack_v1 (absent = cannot acknowledge); `mcp_credentials_present` only once one reported.
39180
+ ...this.reportedMcpAck(),
39143
39181
  // Tool status Phase 0 (spec §3.5, round 2 H-5): an updated bridge, with or without a record yet.
39144
39182
  tool_status_v1: true
39145
39183
  },
@@ -39168,6 +39206,7 @@ var BridgeAgent = class _BridgeAgent {
39168
39206
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
39169
39207
  this.lastReportedSearchProviders = reportedSearchProviders;
39170
39208
  this.lastReportedUtilityTools = reportedUtilityTools;
39209
+ this.lastReportedMcpAck = reportedMcpAck;
39171
39210
  if (version) writeServiceLog("info", "runtime_config.applied", { version });
39172
39211
  else writeServiceLog("info", "runtime_config.legacy_memory_reported");
39173
39212
  }
@@ -43515,7 +43554,12 @@ var BridgeAgent = class _BridgeAgent {
43515
43554
  return;
43516
43555
  }
43517
43556
  this.logManifest(v.state);
43518
- if (v.state === "wrong_owner") this.dropOwnerDmLastTurn();
43557
+ if (v.state === "wrong_owner") {
43558
+ this.dropOwnerDmLastTurn();
43559
+ this.mcpDeletionsThrough = 0;
43560
+ this.mcpPolicyWrittenCounter = null;
43561
+ this.noteMcpAckChanged();
43562
+ }
43519
43563
  if (v.state !== "unchanged" && this.toolGrantManifest) this.logManifest("kept_last_good");
43520
43564
  }
43521
43565
  /**
@@ -45990,6 +46034,118 @@ ${channelId}`;
45990
46034
  clearMcpClientSocket(token) {
45991
46035
  if (this.mcpClientSockets.delete(token)) this.onCapabilityMaybeChanged();
45992
46036
  }
46037
+ /** Deletion-ack ruling §3: ws-server notes every runtime hello's mcp_deletion_ack_v1 declaration (harness: false). */
46038
+ noteMcpDeletionAckSocket(token, declared) {
46039
+ this.mcpDeletionAckSockets.set(token, declared);
46040
+ if (!declared) this.mcpTurnSeq.delete(token);
46041
+ this.forgetCredentialsIfNoAckSocket();
46042
+ this.maybeReportMcpAck();
46043
+ }
46044
+ /** #1351 round 2 observation: with no ack-capable socket left (detached, or re-hello'd without the feature), the last
46045
+ * credentials value is no longer known: forget it, so a later socket does not inherit a stale value. */
46046
+ forgetCredentialsIfNoAckSocket() {
46047
+ if ([...this.mcpDeletionAckSockets.values()].some(Boolean)) return;
46048
+ this.mcpCredentialsPresentValue = null;
46049
+ this.mcpAckClosed = { ...this.mcpAckClosed, credentials: null };
46050
+ if (this.mcpAckOpen !== null) this.mcpAckOpen = { ...this.mcpAckOpen, credentials: null };
46051
+ }
46052
+ clearMcpDeletionAckSocket(token) {
46053
+ this.mcpTurnSeq.delete(token);
46054
+ if (!this.mcpDeletionAckSockets.delete(token)) return;
46055
+ this.forgetCredentialsIfNoAckSocket();
46056
+ this.maybeReportMcpAck();
46057
+ }
46058
+ /** #1351 round 2 M-1: ws-server calls this when it sends an owner-DM turn to an ack-capable socket. Bounded. */
46059
+ noteMcpTurnDispatch(token, messageId) {
46060
+ let m = this.mcpTurnSeq.get(token);
46061
+ if (m === void 0) this.mcpTurnSeq.set(token, m = /* @__PURE__ */ new Map());
46062
+ m.set(messageId, this.policyWriteSeq);
46063
+ while (m.size > 64) m.delete(m.keys().next().value);
46064
+ }
46065
+ /** Ruling §3 / §5.3: the AND over attached sockets, false with none attached (as mcpClientCapable). */
46066
+ mcpDeletionAckCapable() {
46067
+ if (this.mcpDeletionAckSockets.size === 0) return false;
46068
+ for (const v of this.mcpDeletionAckSockets.values()) if (!v) return false;
46069
+ return true;
46070
+ }
46071
+ /**
46072
+ * Deletion-ack ruling (2026-10-31) §3 acceptance. ws-server has validated the frame shape and the socket (declared
46073
+ * mcp_deletion_ack_v1 and mcp_client_v1, no harness). `credentials_present` is taken as is. `applied_sha256` advances
46074
+ * `mcpDeletionsThrough` only when it is the SHA-256 of the bytes THIS bridge last wrote (JSON + newline) and still
46075
+ * holds in force, and that document's top state is one whose deletion set covers the signed removal; then to the
46076
+ * counter recorded at that write, never the live one. Anything else is ignored silently. Never throws.
46077
+ */
46078
+ noteMcpDeletionAck(token, frame) {
46079
+ this.mcpCredentialsPresentValue = frame.credentials_present === true;
46080
+ const json2 = this.mcpPolicyWrittenJson;
46081
+ const doc = this.mcpPolicyWritten;
46082
+ const counter = this.mcpPolicyWrittenCounter;
46083
+ const turns = frame.message_id !== void 0 ? this.mcpTurnSeq.get(token) : void 0;
46084
+ const seqAtDispatch = frame.message_id !== void 0 ? turns?.get(frame.message_id) : void 0;
46085
+ if (frame.message_id !== void 0) turns?.delete(frame.message_id);
46086
+ if (frame.applied_sha256 !== void 0 && seqAtDispatch !== void 0 && seqAtDispatch === this.policyWriteSeq && json2 !== null && doc !== null && counter !== null && MCP_ACK_STATES.has(doc.state) && frame.applied_sha256 === createHash6("sha256").update(json2 + "\n", "utf8").digest("hex")) {
46087
+ this.mcpDeletionsThrough = Math.max(this.mcpDeletionsThrough, counter);
46088
+ }
46089
+ this.noteMcpAckChanged();
46090
+ }
46091
+ /** H-1: a change of the ack values goes into the open bucket; the report sees it only once the bucket has closed. */
46092
+ noteMcpAckChanged() {
46093
+ const now = this.toolStatusNow();
46094
+ this.promoteMcpAck(now);
46095
+ const live = { through: this.mcpDeletionsThrough, credentials: this.mcpCredentialsPresentValue };
46096
+ const base = this.mcpAckOpen ?? this.mcpAckClosed;
46097
+ if (base.through === live.through && base.credentials === live.credentials) return;
46098
+ this.mcpAckOpen = { ...live, end: bucketEndMs(bucketStartAt(now)) };
46099
+ this.scheduleMcpAckReport();
46100
+ }
46101
+ promoteMcpAck(now) {
46102
+ if (this.mcpAckOpen !== null && this.mcpAckOpen.end <= now) {
46103
+ this.mcpAckClosed = { through: this.mcpAckOpen.through, credentials: this.mcpAckOpen.credentials };
46104
+ this.mcpAckOpen = null;
46105
+ }
46106
+ }
46107
+ /** H-1: one timer, at the first bucket boundary strictly after now plus a jitter in [0, 60 s), as the tool-status sender. */
46108
+ scheduleMcpAckReport() {
46109
+ if (this.mcpAckTimer !== null || this.closingDown) return;
46110
+ const now = this.toolStatusNow();
46111
+ const boundary = (Math.floor(now / TOOL_STATUS_BUCKET_MS) + 1) * TOOL_STATUS_BUCKET_MS;
46112
+ const at = boundary + Math.floor(this.toolStatusRandom() * TOOL_STATUS_JITTER_MS);
46113
+ this.mcpAckTimer = this.toolStatusSetTimer(() => {
46114
+ this.mcpAckTimer = null;
46115
+ this.promoteMcpAck(this.toolStatusNow());
46116
+ if (this.lastReportedMcpAck !== this.mcpAckSignature() && this.bootConfigSettled && !this.closingDown) void this.refetchRuntimeConfig();
46117
+ if (this.mcpAckOpen !== null) this.scheduleMcpAckReport();
46118
+ }, Math.max(0, at - now));
46119
+ }
46120
+ /**
46121
+ * Ruling §5.3: the three keys. The two ack values come from the CLOSED bucket only (H-1), so no report, whatever
46122
+ * posts it, reveals an owner-DM turn before its 15-minute bucket has closed. `mcp_r3_hold` is not DM timing: live.
46123
+ */
46124
+ reportedMcpAck() {
46125
+ this.promoteMcpAck(this.toolStatusNow());
46126
+ const ackCapable = this.mcpDeletionAckCapable();
46127
+ const anyAck = [...this.mcpDeletionAckSockets.values()].some(Boolean);
46128
+ const closed = this.mcpAckClosed;
46129
+ return {
46130
+ mcp_r3_hold: this.r3Hold(),
46131
+ ...ackCapable ? { mcp_deletions_through: closed.through } : {},
46132
+ // `anyAck` is an equivalent mutant today (forgetCredentialsIfNoAckSocket nulls the value whenever no ack socket is
46133
+ // left), kept as defense in depth so the report never depends on that reset alone.
46134
+ ...anyAck && closed.credentials !== null ? { mcp_credentials_present: closed.credentials } : {}
46135
+ };
46136
+ }
46137
+ mcpAckSignature() {
46138
+ return JSON.stringify(this.reportedMcpAck());
46139
+ }
46140
+ /**
46141
+ * A SOCKET change (attach, detach, a hello) can change which keys the report carries; their values are the closed
46142
+ * ones, so this is not owner-DM timing and may use the capability refetch. An ACK never comes here (H-1): it goes
46143
+ * through noteMcpAckChanged and the bucket timer.
46144
+ */
46145
+ maybeReportMcpAck() {
46146
+ if (this.lastReportedMcpAck === this.mcpAckSignature()) return;
46147
+ this.scheduleCapabilityRefetch();
46148
+ }
45993
46149
  /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
45994
46150
  noteMcpCatalogLister(token, lister) {
45995
46151
  if (lister === null) this.mcpCatalogListers.delete(token);
@@ -46097,6 +46253,11 @@ ${channelId}`;
46097
46253
  this.recomputeMcpPolicy();
46098
46254
  if (this.closingDown) return;
46099
46255
  if (prev === null && !next.search && !next.utility) return;
46256
+ this.scheduleCapabilityRefetch();
46257
+ }
46258
+ /** Debounced, with a max wait: a burst that never pauses still refetches at most maxWait after its first change. */
46259
+ scheduleCapabilityRefetch() {
46260
+ if (this.closingDown) return;
46100
46261
  const now = Date.now();
46101
46262
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
46102
46263
  else this.searchCapabilityBurstStart = now;
@@ -46114,7 +46275,7 @@ ${channelId}`;
46114
46275
  searchCapabilityRefetchNeeded() {
46115
46276
  if (!this.bootConfigSettled || this.closingDown) return false;
46116
46277
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
46117
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
46278
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable() || this.lastReportedMcpAck !== this.mcpAckSignature();
46118
46279
  }
46119
46280
  /**
46120
46281
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -46341,6 +46502,10 @@ ${channelId}`;
46341
46502
  this.closingDown = true;
46342
46503
  this.statusGate.close();
46343
46504
  this.toolStatusSender.close();
46505
+ if (this.mcpAckTimer !== null) {
46506
+ this.toolStatusClearTimer(this.mcpAckTimer);
46507
+ this.mcpAckTimer = null;
46508
+ }
46344
46509
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
46345
46510
  this.pauseRecheckTimers.clear();
46346
46511
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -47114,6 +47279,10 @@ function attachAgentWebSocket(server, agent, opts) {
47114
47279
  try {
47115
47280
  sock.send(JSON.stringify(frame));
47116
47281
  inflight.set(batch[i].message_id, batch[i]);
47282
+ const a = frame.authorization;
47283
+ if (session.mcpDeletionAck === true && (a?.audience === "owner_dm" || a?.relay_speaker === "owner")) {
47284
+ agent.noteMcpTurnDispatch(session, batch[i].message_id);
47285
+ }
47117
47286
  session.deliveryToken.set(batch[i].message_id, ++session.deliverySeq);
47118
47287
  session.shownRecall.set(batch[i].message_id, { token: session.deliverySeq, ids: recallOut.shown });
47119
47288
  session.shownUnified.set(batch[i].message_id, { token: session.deliverySeq, ids: unifiedOut.shown });
@@ -47162,6 +47331,7 @@ function attachAgentWebSocket(server, agent, opts) {
47162
47331
  agent.clearSearchProvidersSocket(session);
47163
47332
  agent.clearUtilityToolsSocket(session);
47164
47333
  agent.clearMcpClientSocket(session);
47334
+ agent.clearMcpDeletionAckSocket(session);
47165
47335
  agent.noteMcpCatalogLister(session, null);
47166
47336
  for (const settle of session.pendingCatalog.values()) settle(null);
47167
47337
  session.pendingCatalog.clear();
@@ -47300,6 +47470,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47300
47470
  session.mcpClient = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(MANIFEST_GRANTS_FEATURE) && m.features.includes(MCP_CLIENT_FEATURE);
47301
47471
  agent.noteMcpClientSocket(session, session.mcpClient);
47302
47472
  agent.noteMcpCatalogLister(session, session.mcpClient ? mcpCatalogLister(sock, session) : null);
47473
+ session.mcpDeletionAck = session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MCP_DELETION_ACK_FEATURE);
47474
+ agent.noteMcpDeletionAckSocket(session, session.mcpDeletionAck);
47303
47475
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
47304
47476
  agent.noteNotesConsumer();
47305
47477
  }
@@ -47307,7 +47479,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47307
47479
  if (session.recallConsumer) agent.noteRecallConsumer();
47308
47480
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
47309
47481
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
47310
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE, MCP_POLICY_WRITER_FEATURE] });
47482
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE, MCP_POLICY_WRITER_FEATURE, MCP_DELETION_ACK_FEATURE] });
47311
47483
  if (!session.deliveryOpen) {
47312
47484
  session.deliveryOpen = true;
47313
47485
  flush();
@@ -47538,6 +47710,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47538
47710
  }
47539
47711
  return;
47540
47712
  }
47713
+ if (m.type === "mcp_deletion_ack") {
47714
+ const keys = Object.keys(m).sort().join(",");
47715
+ const valid = session.mcpDeletionAck === true && session.mcpClient === true && session.harnessClaimed !== true && (keys === "credentials_present,type" || keys === "credentials_present,message_id,type" || keys === "applied_sha256,credentials_present,message_id,type") && typeof m.credentials_present === "boolean" && (m.message_id === void 0 || typeof m.message_id === "string" && m.message_id.length > 0 && m.message_id.length <= 256) && (m.applied_sha256 === void 0 || typeof m.applied_sha256 === "string" && /^[0-9a-f]{64}$/.test(m.applied_sha256));
47716
+ if (!valid) {
47717
+ sendJson(sock, wsErrorFrame("invalid_request"));
47718
+ return;
47719
+ }
47720
+ agent.noteMcpDeletionAck(session, {
47721
+ credentials_present: m.credentials_present,
47722
+ ...typeof m.message_id === "string" ? { message_id: m.message_id } : {},
47723
+ ...typeof m.applied_sha256 === "string" ? { applied_sha256: m.applied_sha256 } : {}
47724
+ });
47725
+ return;
47726
+ }
47541
47727
  if (m.type === "room_cap_reached") {
47542
47728
  if (!session.manifestGrants || typeof m.channel_id !== "string" || !LOWER_UUID.test(m.channel_id)) {
47543
47729
  sendJson(sock, wsErrorFrame("invalid_request"));
@@ -1240,6 +1240,29 @@ export declare class BridgeAgent {
1240
1240
  */
1241
1241
  private mcpPolicyWritten;
1242
1242
  private mcpPolicyWrittenJson;
1243
+ /** Deletion-ack ruling §3: the manifest counter in force when `mcpPolicyWrittenJson` was written (never read live). */
1244
+ private mcpPolicyWrittenCounter;
1245
+ /** Ruling §3: newest counter whose written policy the runtime fully acted on. Memory only, monotonic, starts at 0. */
1246
+ private mcpDeletionsThrough;
1247
+ /** Ruling §3: the last `credentials_present` an accepted frame carried; null until one arrives. */
1248
+ private mcpCredentialsPresentValue;
1249
+ /** Ruling §3: per attached socket, whether its latest hello declared mcp_deletion_ack_v1 (harness noted false). */
1250
+ private readonly mcpDeletionAckSockets;
1251
+ /** Ruling §3 rate: the ack-derived report values in the last report posted (null until one is). */
1252
+ private lastReportedMcpAck;
1253
+ /** #1351 round 2 M-1: bumped on every byte-changing policy write (and on the inert document being put in force). */
1254
+ private policyWriteSeq;
1255
+ /** M-1: per ack-capable socket, the policyWriteSeq at the dispatch of each owner-DM turn, by message id (bounded). */
1256
+ private readonly mcpTurnSeq;
1257
+ /**
1258
+ * #1351 round 2 H-1: the ack values the REPORT may carry. An ack lands seconds after an owner-DM turn and a report
1259
+ * posted then would time the turn (record_report sets applied_at). So, as the tool-status sender: `open` holds a
1260
+ * change while its 15-minute UTC bucket is open, `closed` what the report carries, and the only post is from one
1261
+ * timer at the next bucket boundary plus jitter.
1262
+ */
1263
+ private mcpAckOpen;
1264
+ private mcpAckClosed;
1265
+ private mcpAckTimer;
1243
1266
  /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
1244
1267
  private readonly mcpPolicyRetryDelaysMs;
1245
1268
  private mcpPolicyRetryTimer;
@@ -3744,6 +3767,45 @@ export declare class BridgeAgent {
3744
3767
  private readonly mcpClientSockets;
3745
3768
  noteMcpClientSocket(token: object, declared: boolean): void;
3746
3769
  clearMcpClientSocket(token: object): void;
3770
+ /** Deletion-ack ruling §3: ws-server notes every runtime hello's mcp_deletion_ack_v1 declaration (harness: false). */
3771
+ noteMcpDeletionAckSocket(token: object, declared: boolean): void;
3772
+ /** #1351 round 2 observation: with no ack-capable socket left (detached, or re-hello'd without the feature), the last
3773
+ * credentials value is no longer known: forget it, so a later socket does not inherit a stale value. */
3774
+ private forgetCredentialsIfNoAckSocket;
3775
+ clearMcpDeletionAckSocket(token: object): void;
3776
+ /** #1351 round 2 M-1: ws-server calls this when it sends an owner-DM turn to an ack-capable socket. Bounded. */
3777
+ noteMcpTurnDispatch(token: object, messageId: string): void;
3778
+ /** Ruling §3 / §5.3: the AND over attached sockets, false with none attached (as mcpClientCapable). */
3779
+ private mcpDeletionAckCapable;
3780
+ /**
3781
+ * Deletion-ack ruling (2026-10-31) §3 acceptance. ws-server has validated the frame shape and the socket (declared
3782
+ * mcp_deletion_ack_v1 and mcp_client_v1, no harness). `credentials_present` is taken as is. `applied_sha256` advances
3783
+ * `mcpDeletionsThrough` only when it is the SHA-256 of the bytes THIS bridge last wrote (JSON + newline) and still
3784
+ * holds in force, and that document's top state is one whose deletion set covers the signed removal; then to the
3785
+ * counter recorded at that write, never the live one. Anything else is ignored silently. Never throws.
3786
+ */
3787
+ noteMcpDeletionAck(token: object, frame: {
3788
+ credentials_present: boolean;
3789
+ message_id?: string;
3790
+ applied_sha256?: string;
3791
+ }): void;
3792
+ /** H-1: a change of the ack values goes into the open bucket; the report sees it only once the bucket has closed. */
3793
+ private noteMcpAckChanged;
3794
+ private promoteMcpAck;
3795
+ /** H-1: one timer, at the first bucket boundary strictly after now plus a jitter in [0, 60 s), as the tool-status sender. */
3796
+ private scheduleMcpAckReport;
3797
+ /**
3798
+ * Ruling §5.3: the three keys. The two ack values come from the CLOSED bucket only (H-1), so no report, whatever
3799
+ * posts it, reveals an owner-DM turn before its 15-minute bucket has closed. `mcp_r3_hold` is not DM timing: live.
3800
+ */
3801
+ private reportedMcpAck;
3802
+ private mcpAckSignature;
3803
+ /**
3804
+ * A SOCKET change (attach, detach, a hello) can change which keys the report carries; their values are the closed
3805
+ * ones, so this is not owner-DM timing and may use the capability refetch. An ACK never comes here (H-1): it goes
3806
+ * through noteMcpAckChanged and the bucket timer.
3807
+ */
3808
+ private maybeReportMcpAck;
3747
3809
  /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
3748
3810
  noteMcpCatalogLister(token: object, lister: McpCatalogLister | null): void;
3749
3811
  /**
@@ -3790,6 +3852,8 @@ export declare class BridgeAgent {
3790
3852
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
3791
3853
  */
3792
3854
  private onCapabilityMaybeChanged;
3855
+ /** Debounced, with a max wait: a burst that never pauses still refetches at most maxWait after its first change. */
3856
+ private scheduleCapabilityRefetch;
3793
3857
  /**
3794
3858
  * A refetch re-renders skills.json (owner_dm_search_cap counts web_extract only with the capability, D39) and posts
3795
3859
  * the report. Needed under a manifest in force, or when the reported value is stale. Never before the boot fetch.
@@ -33,6 +33,13 @@ import { type ServiceLog } from "../service-log.js";
33
33
  * backend can write that name (ws-server.test.ts same-build row).
34
34
  */
35
35
  export declare const MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36
+ /**
37
+ * Deletion-ack ruling (2026-10-31) §3: declared in the hello reply and by the runtime in its hello. Only a socket that
38
+ * declared it (and mcp_client_v1, no harness) may send `mcp_deletion_ack`.
39
+ */
40
+ export declare const MCP_DELETION_ACK_FEATURE = "mcp_deletion_ack_v1";
41
+ /** Ruling §3: the top states whose deletion set covers the manifest's signed `removed` (ok), or everything on disk. */
42
+ export declare const MCP_ACK_STATES: ReadonlySet<string>;
36
43
  export type McpEntitlementReading = "entitled" | "not_entitled" | "suspended";
37
44
  /**
38
45
  * OQ-1 ruling (ADR G). Only `true`/"entitled" is in force and only `false`/"not_entitled" is the deleting downgrade.
package/dist/index.js CHANGED
@@ -29719,6 +29719,7 @@ function checkGroupLink(v) {
29719
29719
  }
29720
29720
 
29721
29721
  // src/bridge-agent.ts
29722
+ import { createHash as createHash6 } from "node:crypto";
29722
29723
  import { AsyncLocalStorage } from "node:async_hooks";
29723
29724
 
29724
29725
  // src/reply-intent.ts
@@ -36696,6 +36697,8 @@ function ownerDmRelayOf(leafClass, leaves, ids) {
36696
36697
 
36697
36698
  // src/grants/mcp-policy.ts
36698
36699
  var MCP_POLICY_WRITER_FEATURE = "mcp_policy_writer_v1";
36700
+ var MCP_DELETION_ACK_FEATURE = "mcp_deletion_ack_v1";
36701
+ var MCP_ACK_STATES = /* @__PURE__ */ new Set(["ok", "not_entitled", "kill_switch"]);
36699
36702
  function mcpEntitlementReading(mcpEntitled, mcpCause) {
36700
36703
  if (mcpEntitled === true && mcpCause === "entitled") return "entitled";
36701
36704
  if (mcpEntitled === false && mcpCause === "not_entitled") return "not_entitled";
@@ -38003,6 +38006,29 @@ var BridgeAgent = class _BridgeAgent {
38003
38006
  */
38004
38007
  mcpPolicyWritten = null;
38005
38008
  mcpPolicyWrittenJson = null;
38009
+ /** Deletion-ack ruling §3: the manifest counter in force when `mcpPolicyWrittenJson` was written (never read live). */
38010
+ mcpPolicyWrittenCounter = null;
38011
+ /** Ruling §3: newest counter whose written policy the runtime fully acted on. Memory only, monotonic, starts at 0. */
38012
+ mcpDeletionsThrough = 0;
38013
+ /** Ruling §3: the last `credentials_present` an accepted frame carried; null until one arrives. */
38014
+ mcpCredentialsPresentValue = null;
38015
+ /** Ruling §3: per attached socket, whether its latest hello declared mcp_deletion_ack_v1 (harness noted false). */
38016
+ mcpDeletionAckSockets = /* @__PURE__ */ new Map();
38017
+ /** Ruling §3 rate: the ack-derived report values in the last report posted (null until one is). */
38018
+ lastReportedMcpAck = null;
38019
+ /** #1351 round 2 M-1: bumped on every byte-changing policy write (and on the inert document being put in force). */
38020
+ policyWriteSeq = 0;
38021
+ /** M-1: per ack-capable socket, the policyWriteSeq at the dispatch of each owner-DM turn, by message id (bounded). */
38022
+ mcpTurnSeq = /* @__PURE__ */ new Map();
38023
+ /**
38024
+ * #1351 round 2 H-1: the ack values the REPORT may carry. An ack lands seconds after an owner-DM turn and a report
38025
+ * posted then would time the turn (record_report sets applied_at). So, as the tool-status sender: `open` holds a
38026
+ * change while its 15-minute UTC bucket is open, `closed` what the report carries, and the only post is from one
38027
+ * timer at the next bucket boundary plus jitter.
38028
+ */
38029
+ mcpAckOpen = null;
38030
+ mcpAckClosed = { through: 0, credentials: null };
38031
+ mcpAckTimer = null;
38006
38032
  /** PR-B2 follow-up: a failed write is retried on this backoff (the last value repeats) until one lands or close(). */
38007
38033
  mcpPolicyRetryDelaysMs;
38008
38034
  mcpPolicyRetryTimer = null;
@@ -38647,15 +38673,22 @@ var BridgeAgent = class _BridgeAgent {
38647
38673
  recomputeMcpPolicy() {
38648
38674
  const doc = mcpPolicyFor(this.mcpPolicyInput());
38649
38675
  const json2 = JSON.stringify(doc);
38650
- if (json2 === this.mcpPolicyWrittenJson) return;
38676
+ if (json2 === this.mcpPolicyWrittenJson) {
38677
+ if (this.mcpPolicyWritten !== null && this.mcpPolicyWrittenCounter !== null) this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38678
+ return;
38679
+ }
38651
38680
  if (MCP_INERT_STATES.has(doc.state) && !doc.r3_hold && Object.keys(doc.servers).length === 0 && !existsSync5(join18(runtimeConfigDir(this.home, this.cfg.identityId), MCP_POLICY_FILE))) {
38652
38681
  this.mcpPolicyWritten = doc;
38653
38682
  this.mcpPolicyWrittenJson = json2;
38683
+ this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38684
+ this.policyWriteSeq++;
38654
38685
  this.clearMcpPolicyRetry();
38655
38686
  return;
38656
38687
  }
38657
38688
  this.mcpPolicyWritten = null;
38658
38689
  this.mcpPolicyWrittenJson = null;
38690
+ this.mcpPolicyWrittenCounter = null;
38691
+ this.policyWriteSeq++;
38659
38692
  try {
38660
38693
  this.grantWriters.writeMcpPolicy(this.home, this.cfg.identityId, doc);
38661
38694
  } catch (e) {
@@ -38665,6 +38698,7 @@ var BridgeAgent = class _BridgeAgent {
38665
38698
  }
38666
38699
  this.mcpPolicyWritten = doc;
38667
38700
  this.mcpPolicyWrittenJson = json2;
38701
+ this.mcpPolicyWrittenCounter = this.toolGrantCounter;
38668
38702
  this.clearMcpPolicyRetry();
38669
38703
  }
38670
38704
  /** A document is in force: no pending retry, and the next failure starts the backoff from the first delay. */
@@ -38780,6 +38814,7 @@ var BridgeAgent = class _BridgeAgent {
38780
38814
  const local = readLocalReport(this.home, this.cfg.identityId);
38781
38815
  const reportedSearchProviders = this.searchProvidersCapable();
38782
38816
  const reportedUtilityTools = this.utilityToolsCapable();
38817
+ const reportedMcpAck = this.mcpAckSignature();
38783
38818
  const carriedOwnerDmTurn = this.toolStatusSender.carry();
38784
38819
  const report = {
38785
38820
  applied_version: version,
@@ -38854,6 +38889,9 @@ var BridgeAgent = class _BridgeAgent {
38854
38889
  // MCP Task 19 (spec §12.3): separate from `capability`; content-free enums and pattern tokens only.
38855
38890
  mcp_capability: this.reportedMcpCapability(),
38856
38891
  mcp_servers: this.reportedMcpServers(),
38892
+ // Deletion-ack ruling §5.3: content-free scalars. `mcp_deletions_through` only while EVERY attached runtime socket
38893
+ // declared mcp_deletion_ack_v1 (absent = cannot acknowledge); `mcp_credentials_present` only once one reported.
38894
+ ...this.reportedMcpAck(),
38857
38895
  // Tool status Phase 0 (spec §3.5, round 2 H-5): an updated bridge, with or without a record yet.
38858
38896
  tool_status_v1: true
38859
38897
  },
@@ -38882,6 +38920,7 @@ var BridgeAgent = class _BridgeAgent {
38882
38920
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
38883
38921
  this.lastReportedSearchProviders = reportedSearchProviders;
38884
38922
  this.lastReportedUtilityTools = reportedUtilityTools;
38923
+ this.lastReportedMcpAck = reportedMcpAck;
38885
38924
  if (version) writeServiceLog2("info", "runtime_config.applied", { version });
38886
38925
  else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
38887
38926
  }
@@ -43229,7 +43268,12 @@ var BridgeAgent = class _BridgeAgent {
43229
43268
  return;
43230
43269
  }
43231
43270
  this.logManifest(v.state);
43232
- if (v.state === "wrong_owner") this.dropOwnerDmLastTurn();
43271
+ if (v.state === "wrong_owner") {
43272
+ this.dropOwnerDmLastTurn();
43273
+ this.mcpDeletionsThrough = 0;
43274
+ this.mcpPolicyWrittenCounter = null;
43275
+ this.noteMcpAckChanged();
43276
+ }
43233
43277
  if (v.state !== "unchanged" && this.toolGrantManifest) this.logManifest("kept_last_good");
43234
43278
  }
43235
43279
  /**
@@ -45704,6 +45748,118 @@ ${channelId}`;
45704
45748
  clearMcpClientSocket(token) {
45705
45749
  if (this.mcpClientSockets.delete(token)) this.onCapabilityMaybeChanged();
45706
45750
  }
45751
+ /** Deletion-ack ruling §3: ws-server notes every runtime hello's mcp_deletion_ack_v1 declaration (harness: false). */
45752
+ noteMcpDeletionAckSocket(token, declared) {
45753
+ this.mcpDeletionAckSockets.set(token, declared);
45754
+ if (!declared) this.mcpTurnSeq.delete(token);
45755
+ this.forgetCredentialsIfNoAckSocket();
45756
+ this.maybeReportMcpAck();
45757
+ }
45758
+ /** #1351 round 2 observation: with no ack-capable socket left (detached, or re-hello'd without the feature), the last
45759
+ * credentials value is no longer known: forget it, so a later socket does not inherit a stale value. */
45760
+ forgetCredentialsIfNoAckSocket() {
45761
+ if ([...this.mcpDeletionAckSockets.values()].some(Boolean)) return;
45762
+ this.mcpCredentialsPresentValue = null;
45763
+ this.mcpAckClosed = { ...this.mcpAckClosed, credentials: null };
45764
+ if (this.mcpAckOpen !== null) this.mcpAckOpen = { ...this.mcpAckOpen, credentials: null };
45765
+ }
45766
+ clearMcpDeletionAckSocket(token) {
45767
+ this.mcpTurnSeq.delete(token);
45768
+ if (!this.mcpDeletionAckSockets.delete(token)) return;
45769
+ this.forgetCredentialsIfNoAckSocket();
45770
+ this.maybeReportMcpAck();
45771
+ }
45772
+ /** #1351 round 2 M-1: ws-server calls this when it sends an owner-DM turn to an ack-capable socket. Bounded. */
45773
+ noteMcpTurnDispatch(token, messageId) {
45774
+ let m = this.mcpTurnSeq.get(token);
45775
+ if (m === void 0) this.mcpTurnSeq.set(token, m = /* @__PURE__ */ new Map());
45776
+ m.set(messageId, this.policyWriteSeq);
45777
+ while (m.size > 64) m.delete(m.keys().next().value);
45778
+ }
45779
+ /** Ruling §3 / §5.3: the AND over attached sockets, false with none attached (as mcpClientCapable). */
45780
+ mcpDeletionAckCapable() {
45781
+ if (this.mcpDeletionAckSockets.size === 0) return false;
45782
+ for (const v of this.mcpDeletionAckSockets.values()) if (!v) return false;
45783
+ return true;
45784
+ }
45785
+ /**
45786
+ * Deletion-ack ruling (2026-10-31) §3 acceptance. ws-server has validated the frame shape and the socket (declared
45787
+ * mcp_deletion_ack_v1 and mcp_client_v1, no harness). `credentials_present` is taken as is. `applied_sha256` advances
45788
+ * `mcpDeletionsThrough` only when it is the SHA-256 of the bytes THIS bridge last wrote (JSON + newline) and still
45789
+ * holds in force, and that document's top state is one whose deletion set covers the signed removal; then to the
45790
+ * counter recorded at that write, never the live one. Anything else is ignored silently. Never throws.
45791
+ */
45792
+ noteMcpDeletionAck(token, frame) {
45793
+ this.mcpCredentialsPresentValue = frame.credentials_present === true;
45794
+ const json2 = this.mcpPolicyWrittenJson;
45795
+ const doc = this.mcpPolicyWritten;
45796
+ const counter = this.mcpPolicyWrittenCounter;
45797
+ const turns = frame.message_id !== void 0 ? this.mcpTurnSeq.get(token) : void 0;
45798
+ const seqAtDispatch = frame.message_id !== void 0 ? turns?.get(frame.message_id) : void 0;
45799
+ if (frame.message_id !== void 0) turns?.delete(frame.message_id);
45800
+ if (frame.applied_sha256 !== void 0 && seqAtDispatch !== void 0 && seqAtDispatch === this.policyWriteSeq && json2 !== null && doc !== null && counter !== null && MCP_ACK_STATES.has(doc.state) && frame.applied_sha256 === createHash6("sha256").update(json2 + "\n", "utf8").digest("hex")) {
45801
+ this.mcpDeletionsThrough = Math.max(this.mcpDeletionsThrough, counter);
45802
+ }
45803
+ this.noteMcpAckChanged();
45804
+ }
45805
+ /** H-1: a change of the ack values goes into the open bucket; the report sees it only once the bucket has closed. */
45806
+ noteMcpAckChanged() {
45807
+ const now = this.toolStatusNow();
45808
+ this.promoteMcpAck(now);
45809
+ const live = { through: this.mcpDeletionsThrough, credentials: this.mcpCredentialsPresentValue };
45810
+ const base = this.mcpAckOpen ?? this.mcpAckClosed;
45811
+ if (base.through === live.through && base.credentials === live.credentials) return;
45812
+ this.mcpAckOpen = { ...live, end: bucketEndMs(bucketStartAt(now)) };
45813
+ this.scheduleMcpAckReport();
45814
+ }
45815
+ promoteMcpAck(now) {
45816
+ if (this.mcpAckOpen !== null && this.mcpAckOpen.end <= now) {
45817
+ this.mcpAckClosed = { through: this.mcpAckOpen.through, credentials: this.mcpAckOpen.credentials };
45818
+ this.mcpAckOpen = null;
45819
+ }
45820
+ }
45821
+ /** H-1: one timer, at the first bucket boundary strictly after now plus a jitter in [0, 60 s), as the tool-status sender. */
45822
+ scheduleMcpAckReport() {
45823
+ if (this.mcpAckTimer !== null || this.closingDown) return;
45824
+ const now = this.toolStatusNow();
45825
+ const boundary = (Math.floor(now / TOOL_STATUS_BUCKET_MS) + 1) * TOOL_STATUS_BUCKET_MS;
45826
+ const at = boundary + Math.floor(this.toolStatusRandom() * TOOL_STATUS_JITTER_MS);
45827
+ this.mcpAckTimer = this.toolStatusSetTimer(() => {
45828
+ this.mcpAckTimer = null;
45829
+ this.promoteMcpAck(this.toolStatusNow());
45830
+ if (this.lastReportedMcpAck !== this.mcpAckSignature() && this.bootConfigSettled && !this.closingDown) void this.refetchRuntimeConfig();
45831
+ if (this.mcpAckOpen !== null) this.scheduleMcpAckReport();
45832
+ }, Math.max(0, at - now));
45833
+ }
45834
+ /**
45835
+ * Ruling §5.3: the three keys. The two ack values come from the CLOSED bucket only (H-1), so no report, whatever
45836
+ * posts it, reveals an owner-DM turn before its 15-minute bucket has closed. `mcp_r3_hold` is not DM timing: live.
45837
+ */
45838
+ reportedMcpAck() {
45839
+ this.promoteMcpAck(this.toolStatusNow());
45840
+ const ackCapable = this.mcpDeletionAckCapable();
45841
+ const anyAck = [...this.mcpDeletionAckSockets.values()].some(Boolean);
45842
+ const closed = this.mcpAckClosed;
45843
+ return {
45844
+ mcp_r3_hold: this.r3Hold(),
45845
+ ...ackCapable ? { mcp_deletions_through: closed.through } : {},
45846
+ // `anyAck` is an equivalent mutant today (forgetCredentialsIfNoAckSocket nulls the value whenever no ack socket is
45847
+ // left), kept as defense in depth so the report never depends on that reset alone.
45848
+ ...anyAck && closed.credentials !== null ? { mcp_credentials_present: closed.credentials } : {}
45849
+ };
45850
+ }
45851
+ mcpAckSignature() {
45852
+ return JSON.stringify(this.reportedMcpAck());
45853
+ }
45854
+ /**
45855
+ * A SOCKET change (attach, detach, a hello) can change which keys the report carries; their values are the closed
45856
+ * ones, so this is not owner-DM timing and may use the capability refetch. An ACK never comes here (H-1): it goes
45857
+ * through noteMcpAckChanged and the bucket timer.
45858
+ */
45859
+ maybeReportMcpAck() {
45860
+ if (this.lastReportedMcpAck === this.mcpAckSignature()) return;
45861
+ this.scheduleCapabilityRefetch();
45862
+ }
45707
45863
  /** MCP Task 18: ws-server registers (or, with null, removes) a socket's loopback catalog lister. */
45708
45864
  noteMcpCatalogLister(token, lister) {
45709
45865
  if (lister === null) this.mcpCatalogListers.delete(token);
@@ -45811,6 +45967,11 @@ ${channelId}`;
45811
45967
  this.recomputeMcpPolicy();
45812
45968
  if (this.closingDown) return;
45813
45969
  if (prev === null && !next.search && !next.utility) return;
45970
+ this.scheduleCapabilityRefetch();
45971
+ }
45972
+ /** Debounced, with a max wait: a burst that never pauses still refetches at most maxWait after its first change. */
45973
+ scheduleCapabilityRefetch() {
45974
+ if (this.closingDown) return;
45814
45975
  const now = Date.now();
45815
45976
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
45816
45977
  else this.searchCapabilityBurstStart = now;
@@ -45828,7 +45989,7 @@ ${channelId}`;
45828
45989
  searchCapabilityRefetchNeeded() {
45829
45990
  if (!this.bootConfigSettled || this.closingDown) return false;
45830
45991
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
45831
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
45992
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable() || this.lastReportedMcpAck !== this.mcpAckSignature();
45832
45993
  }
45833
45994
  /**
45834
45995
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -46055,6 +46216,10 @@ ${channelId}`;
46055
46216
  this.closingDown = true;
46056
46217
  this.statusGate.close();
46057
46218
  this.toolStatusSender.close();
46219
+ if (this.mcpAckTimer !== null) {
46220
+ this.toolStatusClearTimer(this.mcpAckTimer);
46221
+ this.mcpAckTimer = null;
46222
+ }
46058
46223
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
46059
46224
  this.pauseRecheckTimers.clear();
46060
46225
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -46974,6 +47139,10 @@ function attachAgentWebSocket(server, agent, opts) {
46974
47139
  try {
46975
47140
  sock.send(JSON.stringify(frame));
46976
47141
  inflight.set(batch[i].message_id, batch[i]);
47142
+ const a = frame.authorization;
47143
+ if (session.mcpDeletionAck === true && (a?.audience === "owner_dm" || a?.relay_speaker === "owner")) {
47144
+ agent.noteMcpTurnDispatch(session, batch[i].message_id);
47145
+ }
46977
47146
  session.deliveryToken.set(batch[i].message_id, ++session.deliverySeq);
46978
47147
  session.shownRecall.set(batch[i].message_id, { token: session.deliverySeq, ids: recallOut.shown });
46979
47148
  session.shownUnified.set(batch[i].message_id, { token: session.deliverySeq, ids: unifiedOut.shown });
@@ -47022,6 +47191,7 @@ function attachAgentWebSocket(server, agent, opts) {
47022
47191
  agent.clearSearchProvidersSocket(session);
47023
47192
  agent.clearUtilityToolsSocket(session);
47024
47193
  agent.clearMcpClientSocket(session);
47194
+ agent.clearMcpDeletionAckSocket(session);
47025
47195
  agent.noteMcpCatalogLister(session, null);
47026
47196
  for (const settle of session.pendingCatalog.values()) settle(null);
47027
47197
  session.pendingCatalog.clear();
@@ -47160,6 +47330,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47160
47330
  session.mcpClient = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(MANIFEST_GRANTS_FEATURE) && m.features.includes(MCP_CLIENT_FEATURE);
47161
47331
  agent.noteMcpClientSocket(session, session.mcpClient);
47162
47332
  agent.noteMcpCatalogLister(session, session.mcpClient ? mcpCatalogLister(sock, session) : null);
47333
+ session.mcpDeletionAck = session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MCP_DELETION_ACK_FEATURE);
47334
+ agent.noteMcpDeletionAckSocket(session, session.mcpDeletionAck);
47163
47335
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
47164
47336
  agent.noteNotesConsumer();
47165
47337
  }
@@ -47167,7 +47339,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47167
47339
  if (session.recallConsumer) agent.noteRecallConsumer();
47168
47340
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
47169
47341
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
47170
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE, MCP_POLICY_WRITER_FEATURE] });
47342
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE, MCP_POLICY_WRITER_FEATURE, MCP_DELETION_ACK_FEATURE] });
47171
47343
  if (!session.deliveryOpen) {
47172
47344
  session.deliveryOpen = true;
47173
47345
  flush();
@@ -47398,6 +47570,20 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
47398
47570
  }
47399
47571
  return;
47400
47572
  }
47573
+ if (m.type === "mcp_deletion_ack") {
47574
+ const keys = Object.keys(m).sort().join(",");
47575
+ const valid = session.mcpDeletionAck === true && session.mcpClient === true && session.harnessClaimed !== true && (keys === "credentials_present,type" || keys === "credentials_present,message_id,type" || keys === "applied_sha256,credentials_present,message_id,type") && typeof m.credentials_present === "boolean" && (m.message_id === void 0 || typeof m.message_id === "string" && m.message_id.length > 0 && m.message_id.length <= 256) && (m.applied_sha256 === void 0 || typeof m.applied_sha256 === "string" && /^[0-9a-f]{64}$/.test(m.applied_sha256));
47576
+ if (!valid) {
47577
+ sendJson(sock, wsErrorFrame("invalid_request"));
47578
+ return;
47579
+ }
47580
+ agent.noteMcpDeletionAck(session, {
47581
+ credentials_present: m.credentials_present,
47582
+ ...typeof m.message_id === "string" ? { message_id: m.message_id } : {},
47583
+ ...typeof m.applied_sha256 === "string" ? { applied_sha256: m.applied_sha256 } : {}
47584
+ });
47585
+ return;
47586
+ }
47401
47587
  if (m.type === "room_cap_reached") {
47402
47588
  if (!session.manifestGrants || typeof m.channel_id !== "string" || !LOWER_UUID.test(m.channel_id)) {
47403
47589
  sendJson(sock, wsErrorFrame("invalid_request"));
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.36.0";
1
+ export declare const BRIDGE_VERSION = "0.37.0";
@@ -51,7 +51,7 @@ export declare const MEMORY_UNIFIED_FEATURE = "memory_unified";
51
51
  * fake against this list at run time (7b gate fix: a member added here without
52
52
  * a fake made three tests throw "is not a function").
53
53
  */
54
- export declare const WS_AGENT_MEMBERS: readonly ["ackRuntimeMessages", "action", "admitRuntimeDelivery", "applyMemoryNote", "clearHarnessReport", "clearManifestConsumer", "clearPerToolConsumer", "clearSearchProvidersSocket", "clearUtilityToolsSocket", "clearMcpClientSocket", "harnessGrantsWithheld", "signedSearchProvider", "r3Hold", "isRuntimeMessageRetired", "noteConsumerCapability", "noteManifestConsumer", "noteRoomCapReached", "noteSearchProvidersSocket", "noteUtilityToolsSocket", "noteMcpClientSocket", "noteMcpCatalogLister", "noteNotesConsumer", "notePerToolConsumer", "noteRecallConsumer", "noteUnifiedConsumer", "noteSessionClose", "noteSessionEvicted", "noteSessionOpen", "notesAppliedGeneration", "notesForDelivery", "recallForDelivery", "unifiedFeatureOn", "unifiedForDelivery", "recordHarnessReport", "recordRuntimeReply", "reportStatusGated", "requeueFailedRuntimeDeliveries", "requeueFront", "runtimeReplyReceipt", "send", "sendA2A", "subscribe", "takeDeliverable", "wakeListeners"];
54
+ export declare const WS_AGENT_MEMBERS: readonly ["ackRuntimeMessages", "action", "admitRuntimeDelivery", "applyMemoryNote", "clearHarnessReport", "clearManifestConsumer", "clearPerToolConsumer", "clearSearchProvidersSocket", "clearUtilityToolsSocket", "clearMcpClientSocket", "harnessGrantsWithheld", "signedSearchProvider", "r3Hold", "isRuntimeMessageRetired", "noteConsumerCapability", "noteManifestConsumer", "noteRoomCapReached", "noteSearchProvidersSocket", "noteUtilityToolsSocket", "noteMcpClientSocket", "noteMcpCatalogLister", "noteMcpDeletionAckSocket", "clearMcpDeletionAckSocket", "noteMcpDeletionAck", "noteMcpTurnDispatch", "noteNotesConsumer", "notePerToolConsumer", "noteRecallConsumer", "noteUnifiedConsumer", "noteSessionClose", "noteSessionEvicted", "noteSessionOpen", "notesAppliedGeneration", "notesForDelivery", "recallForDelivery", "unifiedFeatureOn", "unifiedForDelivery", "recordHarnessReport", "recordRuntimeReply", "reportStatusGated", "requeueFailedRuntimeDeliveries", "requeueFront", "runtimeReplyReceipt", "send", "sendA2A", "subscribe", "takeDeliverable", "wakeListeners"];
55
55
  export type WsAgent = Pick<BridgeAgent, (typeof WS_AGENT_MEMBERS)[number]>;
56
56
  export declare function attachAgentWebSocket(server: HttpServer, agent: WsAgent, opts: AgentWebSocketOptions): {
57
57
  close(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"