@securecoms/agent-bridge 0.25.3 → 0.25.4

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/bin.js CHANGED
@@ -28838,6 +28838,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28838
28838
 
28839
28839
  // src/bridge-agent.ts
28840
28840
  import { homedir as homedir2 } from "node:os";
28841
+
28842
+ // src/status-gate.ts
28843
+ var STATUS_WORKING_DELAY_MS = 3e3;
28844
+ var StatusGate = class {
28845
+ constructor(publish, opts = {}) {
28846
+ this.publish = publish;
28847
+ this.opts = opts;
28848
+ }
28849
+ entries = /* @__PURE__ */ new Map();
28850
+ chains = /* @__PURE__ */ new Map();
28851
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
28852
+ async report(channelId, state, detail) {
28853
+ if (state === "working") {
28854
+ this.holdWorking(channelId, detail);
28855
+ return;
28856
+ }
28857
+ const entry = this.entries.get(channelId);
28858
+ this.entries.delete(channelId);
28859
+ if (entry?.timer) clearTimeout(entry.timer);
28860
+ if (state === "idle" && entry && !entry.published) return;
28861
+ await this.enqueue(channelId, state, detail);
28862
+ }
28863
+ /** A reply body is about to publish on the channel. */
28864
+ replyStarted(channelId) {
28865
+ const entry = this.entries.get(channelId);
28866
+ if (!entry?.timer) return;
28867
+ clearTimeout(entry.timer);
28868
+ entry.timer = null;
28869
+ }
28870
+ /** Drop every pending "working" (bridge shutdown). */
28871
+ close() {
28872
+ for (const entry of this.entries.values()) if (entry.timer) clearTimeout(entry.timer);
28873
+ this.entries.clear();
28874
+ }
28875
+ holdWorking(channelId, detail) {
28876
+ const existing = this.entries.get(channelId);
28877
+ if (existing && (existing.timer || existing.published)) return;
28878
+ const entry = { timer: null, published: false };
28879
+ this.entries.set(channelId, entry);
28880
+ entry.timer = setTimeout(() => {
28881
+ entry.timer = null;
28882
+ if (this.entries.get(channelId) !== entry) return;
28883
+ entry.published = true;
28884
+ this.enqueue(channelId, "working", detail).catch(() => {
28885
+ });
28886
+ }, this.opts.delayMs ?? STATUS_WORKING_DELAY_MS);
28887
+ entry.timer.unref?.();
28888
+ }
28889
+ /** Publishes in order per channel; rejects with the publish error. */
28890
+ enqueue(channelId, state, detail) {
28891
+ const prev = this.chains.get(channelId) ?? Promise.resolve();
28892
+ const run = prev.then(() => this.publish(channelId, state, detail));
28893
+ const settled = run.catch((err) => {
28894
+ this.opts.onError?.(state, err);
28895
+ });
28896
+ this.chains.set(channelId, settled);
28897
+ void settled.then(() => {
28898
+ if (this.chains.get(channelId) === settled) this.chains.delete(channelId);
28899
+ });
28900
+ return run;
28901
+ }
28902
+ };
28903
+
28904
+ // src/bridge-agent.ts
28841
28905
  function logSendSupersededAdoption(kind) {
28842
28906
  switch (kind) {
28843
28907
  case "adopted":
@@ -29269,6 +29333,15 @@ var BridgeAgent = class _BridgeAgent {
29269
29333
  * either source wins; both read the same identity column.
29270
29334
  */
29271
29335
  lastOwnDisplayName;
29336
+ /**
29337
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
29338
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
29339
+ * See status-gate.ts.
29340
+ */
29341
+ statusGate = new StatusGate(
29342
+ (channelId, state, detail) => this.reportStatus(channelId, state, detail),
29343
+ { onError: (state) => writeServiceLog("warn", "status.publish_failed", { working: state === "working", idle: state === "idle" }) }
29344
+ );
29272
29345
  /**
29273
29346
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29274
29347
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -30800,6 +30873,7 @@ var BridgeAgent = class _BridgeAgent {
30800
30873
  */
30801
30874
  async publishBody(channelId, body, id, onEnvelope) {
30802
30875
  this.enforceBodyRateFloor(channelId, body);
30876
+ this.statusGate.replyStarted(channelId);
30803
30877
  const joined = await this.reconcileJoin(channelId);
30804
30878
  if (!joined) throw new ChannelPendingError(channelId);
30805
30879
  const sendStart = performance.now();
@@ -31913,6 +31987,14 @@ var BridgeAgent = class _BridgeAgent {
31913
31987
  if (TRACE) writeServiceLog("debug", "identity.owner_pin_resolved");
31914
31988
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31915
31989
  }
31990
+ /**
31991
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
31992
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
31993
+ * status-gate.ts. Resolves at once for "working".
31994
+ */
31995
+ async reportStatusGated(channelId, state, detail) {
31996
+ await this.statusGate.report(channelId, state, detail);
31997
+ }
31916
31998
  async reportStatus(channelId, state, detail) {
31917
31999
  const joined = await this.reconcileJoin(channelId);
31918
32000
  if (!joined) throw new ChannelPendingError(channelId);
@@ -33217,6 +33299,7 @@ var BridgeAgent = class _BridgeAgent {
33217
33299
  }
33218
33300
  async close() {
33219
33301
  this.closingDown = true;
33302
+ this.statusGate.close();
33220
33303
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33221
33304
  this.pauseRecheckTimers.clear();
33222
33305
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33563,7 +33646,7 @@ function createHttpServer(agent, opts) {
33563
33646
  if (method === "POST" && path === "/v1/status") {
33564
33647
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33565
33648
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33566
- await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33649
+ await agent.reportStatusGated(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33567
33650
  return json(res, 200, { ok: true });
33568
33651
  }
33569
33652
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33933,7 +34016,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33933
34016
  }
33934
34017
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33935
34018
  try {
33936
- await agent.reportStatus(m.channel_id, m.state, detail);
34019
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33937
34020
  sendJson(sock, { type: "ok" });
33938
34021
  } catch (e) {
33939
34022
  if (e instanceof ChannelPendingError) {
@@ -575,6 +575,12 @@ export declare class BridgeAgent {
575
575
  * either source wins; both read the same identity column.
576
576
  */
577
577
  private lastOwnDisplayName;
578
+ /**
579
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
580
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
581
+ * See status-gate.ts.
582
+ */
583
+ private readonly statusGate;
578
584
  /**
579
585
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
580
586
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -1819,6 +1825,12 @@ export declare class BridgeAgent {
1819
1825
  * never trusted on its own, it is always reconfirmed against the store.
1820
1826
  */
1821
1827
  private resolvePinnedOwnerHook;
1828
+ /**
1829
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
1830
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
1831
+ * status-gate.ts. Resolves at once for "working".
1832
+ */
1833
+ reportStatusGated(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
1822
1834
  reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
1823
1835
  /** `runId` must exist and not have settled yet — settle() is terminal. */
1824
1836
  private requireActionRun;
package/dist/index.js CHANGED
@@ -28687,6 +28687,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28687
28687
 
28688
28688
  // src/bridge-agent.ts
28689
28689
  import { homedir as homedir2 } from "node:os";
28690
+
28691
+ // src/status-gate.ts
28692
+ var STATUS_WORKING_DELAY_MS = 3e3;
28693
+ var StatusGate = class {
28694
+ constructor(publish, opts = {}) {
28695
+ this.publish = publish;
28696
+ this.opts = opts;
28697
+ }
28698
+ entries = /* @__PURE__ */ new Map();
28699
+ chains = /* @__PURE__ */ new Map();
28700
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
28701
+ async report(channelId, state, detail) {
28702
+ if (state === "working") {
28703
+ this.holdWorking(channelId, detail);
28704
+ return;
28705
+ }
28706
+ const entry = this.entries.get(channelId);
28707
+ this.entries.delete(channelId);
28708
+ if (entry?.timer) clearTimeout(entry.timer);
28709
+ if (state === "idle" && entry && !entry.published) return;
28710
+ await this.enqueue(channelId, state, detail);
28711
+ }
28712
+ /** A reply body is about to publish on the channel. */
28713
+ replyStarted(channelId) {
28714
+ const entry = this.entries.get(channelId);
28715
+ if (!entry?.timer) return;
28716
+ clearTimeout(entry.timer);
28717
+ entry.timer = null;
28718
+ }
28719
+ /** Drop every pending "working" (bridge shutdown). */
28720
+ close() {
28721
+ for (const entry of this.entries.values()) if (entry.timer) clearTimeout(entry.timer);
28722
+ this.entries.clear();
28723
+ }
28724
+ holdWorking(channelId, detail) {
28725
+ const existing = this.entries.get(channelId);
28726
+ if (existing && (existing.timer || existing.published)) return;
28727
+ const entry = { timer: null, published: false };
28728
+ this.entries.set(channelId, entry);
28729
+ entry.timer = setTimeout(() => {
28730
+ entry.timer = null;
28731
+ if (this.entries.get(channelId) !== entry) return;
28732
+ entry.published = true;
28733
+ this.enqueue(channelId, "working", detail).catch(() => {
28734
+ });
28735
+ }, this.opts.delayMs ?? STATUS_WORKING_DELAY_MS);
28736
+ entry.timer.unref?.();
28737
+ }
28738
+ /** Publishes in order per channel; rejects with the publish error. */
28739
+ enqueue(channelId, state, detail) {
28740
+ const prev = this.chains.get(channelId) ?? Promise.resolve();
28741
+ const run = prev.then(() => this.publish(channelId, state, detail));
28742
+ const settled = run.catch((err) => {
28743
+ this.opts.onError?.(state, err);
28744
+ });
28745
+ this.chains.set(channelId, settled);
28746
+ void settled.then(() => {
28747
+ if (this.chains.get(channelId) === settled) this.chains.delete(channelId);
28748
+ });
28749
+ return run;
28750
+ }
28751
+ };
28752
+
28753
+ // src/bridge-agent.ts
28690
28754
  function logSendSupersededAdoption(kind) {
28691
28755
  switch (kind) {
28692
28756
  case "adopted":
@@ -29118,6 +29182,15 @@ var BridgeAgent = class _BridgeAgent {
29118
29182
  * either source wins; both read the same identity column.
29119
29183
  */
29120
29184
  lastOwnDisplayName;
29185
+ /**
29186
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
29187
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
29188
+ * See status-gate.ts.
29189
+ */
29190
+ statusGate = new StatusGate(
29191
+ (channelId, state, detail) => this.reportStatus(channelId, state, detail),
29192
+ { onError: (state) => writeServiceLog2("warn", "status.publish_failed", { working: state === "working", idle: state === "idle" }) }
29193
+ );
29121
29194
  /**
29122
29195
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29123
29196
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -30649,6 +30722,7 @@ var BridgeAgent = class _BridgeAgent {
30649
30722
  */
30650
30723
  async publishBody(channelId, body, id, onEnvelope) {
30651
30724
  this.enforceBodyRateFloor(channelId, body);
30725
+ this.statusGate.replyStarted(channelId);
30652
30726
  const joined = await this.reconcileJoin(channelId);
30653
30727
  if (!joined) throw new ChannelPendingError(channelId);
30654
30728
  const sendStart = performance.now();
@@ -31762,6 +31836,14 @@ var BridgeAgent = class _BridgeAgent {
31762
31836
  if (TRACE) writeServiceLog2("debug", "identity.owner_pin_resolved");
31763
31837
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31764
31838
  }
31839
+ /**
31840
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
31841
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
31842
+ * status-gate.ts. Resolves at once for "working".
31843
+ */
31844
+ async reportStatusGated(channelId, state, detail) {
31845
+ await this.statusGate.report(channelId, state, detail);
31846
+ }
31765
31847
  async reportStatus(channelId, state, detail) {
31766
31848
  const joined = await this.reconcileJoin(channelId);
31767
31849
  if (!joined) throw new ChannelPendingError(channelId);
@@ -33066,6 +33148,7 @@ var BridgeAgent = class _BridgeAgent {
33066
33148
  }
33067
33149
  async close() {
33068
33150
  this.closingDown = true;
33151
+ this.statusGate.close();
33069
33152
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33070
33153
  this.pauseRecheckTimers.clear();
33071
33154
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33519,7 +33602,7 @@ function createHttpServer(agent, opts) {
33519
33602
  if (method === "POST" && path === "/v1/status") {
33520
33603
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33521
33604
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33522
- await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33605
+ await agent.reportStatusGated(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33523
33606
  return json(res, 200, { ok: true });
33524
33607
  }
33525
33608
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33889,7 +33972,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33889
33972
  }
33890
33973
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33891
33974
  try {
33892
- await agent.reportStatus(m.channel_id, m.state, detail);
33975
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33893
33976
  sendJson(sock, { type: "ok" });
33894
33977
  } catch (e) {
33895
33978
  if (e instanceof ChannelPendingError) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thinking-indicator gate (owner rule, 2026-10-01): a reply that comes back
3
+ * within {@link STATUS_WORKING_DELAY_MS} shows no "thinking..." strip at all;
4
+ * a longer turn shows it.
5
+ *
6
+ * Every status is an MLS publish (about 4.4 s in prod) on the same per-tenant
7
+ * send path as the reply, so an eager "working" made quick replies wait behind
8
+ * it. The gate holds "working" for the delay and publishes it only if the turn
9
+ * is still running then:
10
+ *
11
+ * - "idle" before the delay cancels the pending "working" and publishes
12
+ * nothing (the strip was never shown, so there is nothing to clear);
13
+ * - a reply starting to publish on the channel also cancels it, because
14
+ * some runtimes (OpenClaw) send "idle" only after the reply publish has
15
+ * settled, which would otherwise raise the strip under a visible answer;
16
+ * - once "working" is published, "idle" is published after it, in order;
17
+ * - "stalled" / "error" publish at once (after anything queued) and drop a
18
+ * pending "working".
19
+ *
20
+ * Only the agent-facing status routes (HTTP /v1/status, WS "status") go
21
+ * through the gate. BridgeAgent.reportStatus itself still publishes at once.
22
+ */
23
+ export declare const STATUS_WORKING_DELAY_MS = 3000;
24
+ export type AgentStatusState = "idle" | "working" | "stalled" | "error";
25
+ type Publish = (channelId: string, state: AgentStatusState, detail?: string) => Promise<void>;
26
+ export declare class StatusGate {
27
+ private readonly publish;
28
+ private readonly opts;
29
+ private readonly entries;
30
+ private readonly chains;
31
+ constructor(publish: Publish, opts?: {
32
+ delayMs?: number;
33
+ onError?: (state: AgentStatusState, err: unknown) => void;
34
+ });
35
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
36
+ report(channelId: string, state: AgentStatusState, detail?: string): Promise<void>;
37
+ /** A reply body is about to publish on the channel. */
38
+ replyStarted(channelId: string): void;
39
+ /** Drop every pending "working" (bridge shutdown). */
40
+ close(): void;
41
+ private holdWorking;
42
+ /** Publishes in order per channel; rejects with the publish error. */
43
+ private enqueue;
44
+ }
45
+ export {};
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.25.3";
1
+ export declare const BRIDGE_VERSION = "0.25.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"