@securecoms/agent-bridge 0.25.2 → 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
@@ -7436,10 +7436,10 @@ var HANDOFF = {
7436
7436
  balanced: "If the message needs files, tools, memory lookups, current data, or any work you cannot do instantly, say in one sentence that you are checking and end your reply with the exact token [[DEFER]] so the full agent answers next. For greetings, small talk, and anything you can answer completely yourself, answer completely and do NOT add the token.",
7437
7437
  self: "Answer yourself whenever you reasonably can. Only when the message clearly needs files, tools, or current data you cannot see, say in one sentence that you are checking and end your reply with the exact token [[DEFER]]."
7438
7438
  };
7439
- function compileVoicePrompt(s) {
7439
+ function compileVoicePrompt(s, agentName) {
7440
7440
  const parts = [];
7441
7441
  const persona = s.persona.trim();
7442
- const displayName = s.displayName.trim();
7442
+ const displayName = (agentName ?? "").trim();
7443
7443
  if (displayName && !persona.toLowerCase().includes(displayName.toLowerCase())) {
7444
7444
  parts.push(`You are ${displayName}.`);
7445
7445
  }
@@ -7491,6 +7491,9 @@ var Row = z.object({
7491
7491
  voice_id: z.string().nullable().optional(),
7492
7492
  pace: z.enum(PACES).default("normal"),
7493
7493
  display_name: z.string().max(40).default(""),
7494
+ // Owner decision 2026-10-01 ("one name"). Lenient: a bad value is dropped
7495
+ // (see identityName below), never a reason to reject the whole row.
7496
+ identity_display_name: z.unknown().optional(),
7494
7497
  persona: z.string().max(600).default(""),
7495
7498
  warmth: z.enum(WARMTHS).default("friendly"),
7496
7499
  formality: z.enum(FORMALITIES).default("neutral"),
@@ -7501,8 +7504,14 @@ var Row = z.object({
7501
7504
  avoid_topics: z.array(z.string().max(80)).max(10).default([]),
7502
7505
  handoff: z.enum(HANDOFFS).default("balanced")
7503
7506
  }).passthrough();
7507
+ function identityName(v) {
7508
+ if (typeof v !== "string") return void 0;
7509
+ const name = v.trim();
7510
+ return name && name.length <= 200 ? name : void 0;
7511
+ }
7504
7512
  function parseVoiceSettings(raw) {
7505
7513
  const r = Row.parse(raw);
7514
+ const identityDisplayName = identityName(r.identity_display_name);
7506
7515
  return {
7507
7516
  version: r.version,
7508
7517
  voiceId: r.voice_id ?? null,
@@ -7516,7 +7525,8 @@ function parseVoiceSettings(raw) {
7516
7525
  quirks: r.quirks,
7517
7526
  greetingStyle: r.greeting_style,
7518
7527
  avoidTopics: r.avoid_topics,
7519
- handoff: r.handoff
7528
+ handoff: r.handoff,
7529
+ ...identityDisplayName ? { identityDisplayName } : {}
7520
7530
  };
7521
7531
  }
7522
7532
  function settingsToVoiceConfig(s, proxy) {
@@ -7577,10 +7587,11 @@ function parseVoiceConfig(raw, env) {
7577
7587
  }
7578
7588
  };
7579
7589
  }
7580
- function systemPrompt(p, settings) {
7590
+ function systemPrompt(p, settings, agentName) {
7581
7591
  const core = p.persona.trim().endsWith(CONTINUITY_CLAUSE) ? p.persona.trim().slice(0, -CONTINUITY_CLAUSE.length).trim() : p.persona.trim();
7582
7592
  return compileVoicePrompt(
7583
- settings ?? { ...DEFAULT_VOICE_SETTINGS, displayName: p.displayName ?? "", persona: core, quirks: p.quirks }
7593
+ settings ?? { ...DEFAULT_VOICE_SETTINGS, persona: core, quirks: p.quirks },
7594
+ agentName
7584
7595
  );
7585
7596
  }
7586
7597
 
@@ -28350,7 +28361,7 @@ function clampContent(content, max = MAX_MESSAGE_CHARS) {
28350
28361
  }
28351
28362
  function buildFastMessages(args2) {
28352
28363
  const cap = args2.charCap ?? 4e3;
28353
- const sp = systemPrompt(args2.persona, args2.settings);
28364
+ const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
28354
28365
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
28355
28366
  const system = `${head} ${args2.guidance.trim()} ${CONTINUITY_CLAUSE}`;
28356
28367
  const recent = args2.memory.recent.map((t) => ({
@@ -28827,6 +28838,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28827
28838
 
28828
28839
  // src/bridge-agent.ts
28829
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
28830
28905
  function logSendSupersededAdoption(kind) {
28831
28906
  switch (kind) {
28832
28907
  case "adopted":
@@ -29250,8 +29325,23 @@ var BridgeAgent = class _BridgeAgent {
29250
29325
  * `roomDisplayName`. Kept across a later failed refresh, so a transient
29251
29326
  * members-fetch error does not make the agent deaf to its own name; a
29252
29327
  * successful refresh after a rename replaces it.
29328
+ *
29329
+ * 2026-10-01 ("one name"): also the name the voice fast path speaks as
29330
+ * ("You are <name>."). A voice settings fetch that carries
29331
+ * `identity_display_name` writes it too (the backend pings
29332
+ * `voice_settings_changed` on a rename), so the latest observation from
29333
+ * either source wins; both read the same identity column.
29253
29334
  */
29254
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
+ );
29255
29345
  /**
29256
29346
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29257
29347
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -29653,6 +29743,7 @@ var BridgeAgent = class _BridgeAgent {
29653
29743
  const res = await this.fetchBounded("voice_settings", "read", url, { headers: { authorization: `Bearer ${this.cfg.apiKey}` } });
29654
29744
  if (!res.ok) throw new Error(`voice-settings ${res.status}`);
29655
29745
  const s = parseVoiceSettings(await res.json());
29746
+ if (s.identityDisplayName) this.lastOwnDisplayName = s.identityDisplayName;
29656
29747
  const proxy = settingsToVoiceConfig(s, { baseUrl: this.cfg.apiBase, apiKey: this.cfg.apiKey });
29657
29748
  if (s.version === 0 && this.fileVoiceConfig) {
29658
29749
  const merged = {
@@ -30782,6 +30873,7 @@ var BridgeAgent = class _BridgeAgent {
30782
30873
  */
30783
30874
  async publishBody(channelId, body, id, onEnvelope) {
30784
30875
  this.enforceBodyRateFloor(channelId, body);
30876
+ this.statusGate.replyStarted(channelId);
30785
30877
  const joined = await this.reconcileJoin(channelId);
30786
30878
  if (!joined) throw new ChannelPendingError(channelId);
30787
30879
  const sendStart = performance.now();
@@ -31550,7 +31642,8 @@ var BridgeAgent = class _BridgeAgent {
31550
31642
  memory,
31551
31643
  speaker,
31552
31644
  text,
31553
- settings: this.voiceSettings ?? void 0
31645
+ settings: this.voiceSettings ?? void 0,
31646
+ agentName: this.voiceAgentName()
31554
31647
  });
31555
31648
  const maxSentences = this.voiceSettings?.brevity === "one" ? 1 : 2;
31556
31649
  const { reply, defer } = splitFastReply(await this.fastLlm.complete(messages), 200, maxSentences);
@@ -31894,6 +31987,14 @@ var BridgeAgent = class _BridgeAgent {
31894
31987
  if (TRACE) writeServiceLog("debug", "identity.owner_pin_resolved");
31895
31988
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31896
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
+ }
31897
31998
  async reportStatus(channelId, state, detail) {
31898
31999
  const joined = await this.reconcileJoin(channelId);
31899
32000
  if (!joined) throw new ChannelPendingError(channelId);
@@ -32857,6 +32958,16 @@ var BridgeAgent = class _BridgeAgent {
32857
32958
  ownRoomDisplayName() {
32858
32959
  return this.lastOwnDisplayName;
32859
32960
  }
32961
+ /**
32962
+ * 2026-10-01 (owner decision, "one name"): the name the voice prompt uses.
32963
+ * The agent's CURRENT identity display name (roster or voice settings
32964
+ * fetch, whichever saw it last); before either has, the credential's
32965
+ * agent_name (the same identity name as of credential issue). Never the
32966
+ * legacy voice settings `display_name` or the file `voice.persona.display_name`.
32967
+ */
32968
+ voiceAgentName() {
32969
+ return this.lastOwnDisplayName ?? this.cfg.agentName;
32970
+ }
32860
32971
  /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
32861
32972
  loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
32862
32973
  if (relayedHumanTurn) return false;
@@ -33188,6 +33299,7 @@ var BridgeAgent = class _BridgeAgent {
33188
33299
  }
33189
33300
  async close() {
33190
33301
  this.closingDown = true;
33302
+ this.statusGate.close();
33191
33303
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33192
33304
  this.pauseRecheckTimers.clear();
33193
33305
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33534,7 +33646,7 @@ function createHttpServer(agent, opts) {
33534
33646
  if (method === "POST" && path === "/v1/status") {
33535
33647
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33536
33648
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33537
- 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);
33538
33650
  return json(res, 200, { ok: true });
33539
33651
  }
33540
33652
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33904,7 +34016,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33904
34016
  }
33905
34017
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33906
34018
  try {
33907
- await agent.reportStatus(m.channel_id, m.state, detail);
34019
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33908
34020
  sendJson(sock, { type: "ok" });
33909
34021
  } catch (e) {
33910
34022
  if (e instanceof ChannelPendingError) {
@@ -567,8 +567,20 @@ export declare class BridgeAgent {
567
567
  * `roomDisplayName`. Kept across a later failed refresh, so a transient
568
568
  * members-fetch error does not make the agent deaf to its own name; a
569
569
  * successful refresh after a rename replaces it.
570
+ *
571
+ * 2026-10-01 ("one name"): also the name the voice fast path speaks as
572
+ * ("You are <name>."). A voice settings fetch that carries
573
+ * `identity_display_name` writes it too (the backend pings
574
+ * `voice_settings_changed` on a rename), so the latest observation from
575
+ * either source wins; both read the same identity column.
570
576
  */
571
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;
572
584
  /**
573
585
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
574
586
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -1813,6 +1825,12 @@ export declare class BridgeAgent {
1813
1825
  * never trusted on its own, it is always reconfirmed against the store.
1814
1826
  */
1815
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>;
1816
1834
  reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
1817
1835
  /** `runId` must exist and not have settled yet — settle() is terminal. */
1818
1836
  private requireActionRun;
@@ -2001,6 +2019,14 @@ export declare class BridgeAgent {
2001
2019
  * the identity id.
2002
2020
  */
2003
2021
  private ownRoomDisplayName;
2022
+ /**
2023
+ * 2026-10-01 (owner decision, "one name"): the name the voice prompt uses.
2024
+ * The agent's CURRENT identity display name (roster or voice settings
2025
+ * fetch, whichever saw it last); before either has, the credential's
2026
+ * agent_name (the same identity name as of credential issue). Never the
2027
+ * legacy voice settings `display_name` or the file `voice.persona.display_name`.
2028
+ */
2029
+ private voiceAgentName;
2004
2030
  /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
2005
2031
  private loopSenderIsAgent;
2006
2032
  private noteAgentTurnSuppressed;
package/dist/index.js CHANGED
@@ -28041,10 +28041,10 @@ var HANDOFF = {
28041
28041
  balanced: "If the message needs files, tools, memory lookups, current data, or any work you cannot do instantly, say in one sentence that you are checking and end your reply with the exact token [[DEFER]] so the full agent answers next. For greetings, small talk, and anything you can answer completely yourself, answer completely and do NOT add the token.",
28042
28042
  self: "Answer yourself whenever you reasonably can. Only when the message clearly needs files, tools, or current data you cannot see, say in one sentence that you are checking and end your reply with the exact token [[DEFER]]."
28043
28043
  };
28044
- function compileVoicePrompt(s) {
28044
+ function compileVoicePrompt(s, agentName) {
28045
28045
  const parts = [];
28046
28046
  const persona = s.persona.trim();
28047
- const displayName = s.displayName.trim();
28047
+ const displayName = (agentName ?? "").trim();
28048
28048
  if (displayName && !persona.toLowerCase().includes(displayName.toLowerCase())) {
28049
28049
  parts.push(`You are ${displayName}.`);
28050
28050
  }
@@ -28096,6 +28096,9 @@ var Row = z.object({
28096
28096
  voice_id: z.string().nullable().optional(),
28097
28097
  pace: z.enum(PACES).default("normal"),
28098
28098
  display_name: z.string().max(40).default(""),
28099
+ // Owner decision 2026-10-01 ("one name"). Lenient: a bad value is dropped
28100
+ // (see identityName below), never a reason to reject the whole row.
28101
+ identity_display_name: z.unknown().optional(),
28099
28102
  persona: z.string().max(600).default(""),
28100
28103
  warmth: z.enum(WARMTHS).default("friendly"),
28101
28104
  formality: z.enum(FORMALITIES).default("neutral"),
@@ -28106,8 +28109,14 @@ var Row = z.object({
28106
28109
  avoid_topics: z.array(z.string().max(80)).max(10).default([]),
28107
28110
  handoff: z.enum(HANDOFFS).default("balanced")
28108
28111
  }).passthrough();
28112
+ function identityName(v) {
28113
+ if (typeof v !== "string") return void 0;
28114
+ const name = v.trim();
28115
+ return name && name.length <= 200 ? name : void 0;
28116
+ }
28109
28117
  function parseVoiceSettings(raw) {
28110
28118
  const r = Row.parse(raw);
28119
+ const identityDisplayName = identityName(r.identity_display_name);
28111
28120
  return {
28112
28121
  version: r.version,
28113
28122
  voiceId: r.voice_id ?? null,
@@ -28121,7 +28130,8 @@ function parseVoiceSettings(raw) {
28121
28130
  quirks: r.quirks,
28122
28131
  greetingStyle: r.greeting_style,
28123
28132
  avoidTopics: r.avoid_topics,
28124
- handoff: r.handoff
28133
+ handoff: r.handoff,
28134
+ ...identityDisplayName ? { identityDisplayName } : {}
28125
28135
  };
28126
28136
  }
28127
28137
  function settingsToVoiceConfig(s, proxy) {
@@ -28182,10 +28192,11 @@ function parseVoiceConfig(raw, env) {
28182
28192
  }
28183
28193
  };
28184
28194
  }
28185
- function systemPrompt(p, settings) {
28195
+ function systemPrompt(p, settings, agentName) {
28186
28196
  const core = p.persona.trim().endsWith(CONTINUITY_CLAUSE) ? p.persona.trim().slice(0, -CONTINUITY_CLAUSE.length).trim() : p.persona.trim();
28187
28197
  return compileVoicePrompt(
28188
- settings ?? { ...DEFAULT_VOICE_SETTINGS, displayName: p.displayName ?? "", persona: core, quirks: p.quirks }
28198
+ settings ?? { ...DEFAULT_VOICE_SETTINGS, persona: core, quirks: p.quirks },
28199
+ agentName
28189
28200
  );
28190
28201
  }
28191
28202
 
@@ -28199,7 +28210,7 @@ function clampContent(content, max = MAX_MESSAGE_CHARS) {
28199
28210
  }
28200
28211
  function buildFastMessages(args2) {
28201
28212
  const cap = args2.charCap ?? 4e3;
28202
- const sp = systemPrompt(args2.persona, args2.settings);
28213
+ const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
28203
28214
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
28204
28215
  const system = `${head} ${args2.guidance.trim()} ${CONTINUITY_CLAUSE}`;
28205
28216
  const recent = args2.memory.recent.map((t) => ({
@@ -28676,6 +28687,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28676
28687
 
28677
28688
  // src/bridge-agent.ts
28678
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
28679
28754
  function logSendSupersededAdoption(kind) {
28680
28755
  switch (kind) {
28681
28756
  case "adopted":
@@ -29099,8 +29174,23 @@ var BridgeAgent = class _BridgeAgent {
29099
29174
  * `roomDisplayName`. Kept across a later failed refresh, so a transient
29100
29175
  * members-fetch error does not make the agent deaf to its own name; a
29101
29176
  * successful refresh after a rename replaces it.
29177
+ *
29178
+ * 2026-10-01 ("one name"): also the name the voice fast path speaks as
29179
+ * ("You are <name>."). A voice settings fetch that carries
29180
+ * `identity_display_name` writes it too (the backend pings
29181
+ * `voice_settings_changed` on a rename), so the latest observation from
29182
+ * either source wins; both read the same identity column.
29102
29183
  */
29103
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
+ );
29104
29194
  /**
29105
29195
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29106
29196
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -29502,6 +29592,7 @@ var BridgeAgent = class _BridgeAgent {
29502
29592
  const res = await this.fetchBounded("voice_settings", "read", url, { headers: { authorization: `Bearer ${this.cfg.apiKey}` } });
29503
29593
  if (!res.ok) throw new Error(`voice-settings ${res.status}`);
29504
29594
  const s = parseVoiceSettings(await res.json());
29595
+ if (s.identityDisplayName) this.lastOwnDisplayName = s.identityDisplayName;
29505
29596
  const proxy = settingsToVoiceConfig(s, { baseUrl: this.cfg.apiBase, apiKey: this.cfg.apiKey });
29506
29597
  if (s.version === 0 && this.fileVoiceConfig) {
29507
29598
  const merged = {
@@ -30631,6 +30722,7 @@ var BridgeAgent = class _BridgeAgent {
30631
30722
  */
30632
30723
  async publishBody(channelId, body, id, onEnvelope) {
30633
30724
  this.enforceBodyRateFloor(channelId, body);
30725
+ this.statusGate.replyStarted(channelId);
30634
30726
  const joined = await this.reconcileJoin(channelId);
30635
30727
  if (!joined) throw new ChannelPendingError(channelId);
30636
30728
  const sendStart = performance.now();
@@ -31399,7 +31491,8 @@ var BridgeAgent = class _BridgeAgent {
31399
31491
  memory,
31400
31492
  speaker,
31401
31493
  text,
31402
- settings: this.voiceSettings ?? void 0
31494
+ settings: this.voiceSettings ?? void 0,
31495
+ agentName: this.voiceAgentName()
31403
31496
  });
31404
31497
  const maxSentences = this.voiceSettings?.brevity === "one" ? 1 : 2;
31405
31498
  const { reply, defer } = splitFastReply(await this.fastLlm.complete(messages), 200, maxSentences);
@@ -31743,6 +31836,14 @@ var BridgeAgent = class _BridgeAgent {
31743
31836
  if (TRACE) writeServiceLog2("debug", "identity.owner_pin_resolved");
31744
31837
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31745
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
+ }
31746
31847
  async reportStatus(channelId, state, detail) {
31747
31848
  const joined = await this.reconcileJoin(channelId);
31748
31849
  if (!joined) throw new ChannelPendingError(channelId);
@@ -32706,6 +32807,16 @@ var BridgeAgent = class _BridgeAgent {
32706
32807
  ownRoomDisplayName() {
32707
32808
  return this.lastOwnDisplayName;
32708
32809
  }
32810
+ /**
32811
+ * 2026-10-01 (owner decision, "one name"): the name the voice prompt uses.
32812
+ * The agent's CURRENT identity display name (roster or voice settings
32813
+ * fetch, whichever saw it last); before either has, the credential's
32814
+ * agent_name (the same identity name as of credential issue). Never the
32815
+ * legacy voice settings `display_name` or the file `voice.persona.display_name`.
32816
+ */
32817
+ voiceAgentName() {
32818
+ return this.lastOwnDisplayName ?? this.cfg.agentName;
32819
+ }
32709
32820
  /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
32710
32821
  loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
32711
32822
  if (relayedHumanTurn) return false;
@@ -33037,6 +33148,7 @@ var BridgeAgent = class _BridgeAgent {
33037
33148
  }
33038
33149
  async close() {
33039
33150
  this.closingDown = true;
33151
+ this.statusGate.close();
33040
33152
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33041
33153
  this.pauseRecheckTimers.clear();
33042
33154
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33490,7 +33602,7 @@ function createHttpServer(agent, opts) {
33490
33602
  if (method === "POST" && path === "/v1/status") {
33491
33603
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33492
33604
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33493
- 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);
33494
33606
  return json(res, 200, { ok: true });
33495
33607
  }
33496
33608
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33860,7 +33972,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33860
33972
  }
33861
33973
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33862
33974
  try {
33863
- await agent.reportStatus(m.channel_id, m.state, detail);
33975
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33864
33976
  sendJson(sock, { type: "ok" });
33865
33977
  } catch (e) {
33866
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.2";
1
+ export declare const BRIDGE_VERSION = "0.25.4";
@@ -35,6 +35,9 @@ export declare function buildFastMessages(args: {
35
35
  * from these backend-loaded dials (compileVoicePrompt) instead of the
36
36
  * credential file's persona block. */
37
37
  settings?: VoiceSettings;
38
+ /** The agent's identity display name ("one name", 2026-10-01): the only
39
+ * source of the prompt's "You are <name>." line. */
40
+ agentName?: string;
38
41
  }): ChatMessage[];
39
42
  /** The fast model appends this when the full agent must answer next. */
40
43
  export declare const DEFER_TOKEN = "[[DEFER]]";
@@ -1,5 +1,11 @@
1
1
  import { type VoiceSettings } from "./settings.js";
2
2
  export declare const CONTINUITY_CLAUSE: string;
3
+ /**
4
+ * `displayName` is legacy (the credential file's `voice.persona.display_name`,
5
+ * or the backend's voice settings `display_name`). It is still parsed so old
6
+ * credentials and payloads load, but the prompt never reads it: the agent's
7
+ * name comes from its identity display name (owner decision 2026-10-01, "one name").
8
+ */
3
9
  export interface VoicePersona {
4
10
  displayName?: string;
5
11
  persona: string;
@@ -20,4 +26,4 @@ export interface VoiceConfig {
20
26
  settings?: VoiceSettings;
21
27
  }
22
28
  export declare function parseVoiceConfig(raw: unknown, env: Record<string, string | undefined>): VoiceConfig | undefined;
23
- export declare function systemPrompt(p: VoicePersona, settings?: VoiceSettings): string;
29
+ export declare function systemPrompt(p: VoicePersona, settings?: VoiceSettings, agentName?: string): string;
@@ -1,4 +1,11 @@
1
1
  import type { VoiceSettings } from "./settings.js";
2
- export declare function compileVoicePrompt(s: VoiceSettings): string;
2
+ /**
3
+ * `agentName` is the agent's identity display name (`ConnectIdentity.display_name`,
4
+ * the one name shown in rosters and the Directory). Owner decision 2026-10-01
5
+ * ("one name"): the voice settings `display_name` (`s.displayName`) is a legacy
6
+ * field that is still parsed for compatibility but never read here, so a
7
+ * rename reaches calls too.
8
+ */
9
+ export declare function compileVoicePrompt(s: VoiceSettings, agentName?: string): string;
3
10
  export declare const VOICE_CALL_START_MARKER = "[voice call started]";
4
11
  export declare function greetingTurnFor(s: VoiceSettings, callerName?: string): string;
@@ -10,7 +10,13 @@ export interface VoiceSettings {
10
10
  version: number;
11
11
  voiceId: string | null;
12
12
  pace: (typeof PACES)[number];
13
+ /** Legacy voice-only name (backend `display_name`). Parsed for compatibility
14
+ * with older backends/web clients, never used for the prompt ("one name",
15
+ * 2026-10-01). */
13
16
  displayName: string;
17
+ /** The agent's identity display name at fetch time (backend
18
+ * `identity_display_name`), when the backend sends it. */
19
+ identityDisplayName?: string;
14
20
  persona: string;
15
21
  warmth: (typeof WARMTHS)[number];
16
22
  formality: (typeof FORMALITIES)[number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"