@nopeek/agent-bridge 0.7.20 → 0.7.22

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/backends.js CHANGED
@@ -620,6 +620,10 @@ export function hermesBrain(cfg) {
620
620
  const reply = await http(text, ctx, hooks);
621
621
  if (reply)
622
622
  return reply;
623
+ // A mid-turn abort is intentional (user follow-up). Do not spawn a
624
+ // second Hermes CLI job — that is how empty "…" bubbles stacked up.
625
+ if (ctx.signal?.aborted)
626
+ return "";
623
627
  console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
624
628
  }
625
629
  return cli(text, ctx, hooks);
package/dist/bot.d.ts CHANGED
@@ -64,6 +64,8 @@ export declare class BotRunner {
64
64
  /** Fire-and-forget: runs the connect loop in the background, isolated. */
65
65
  start(): void;
66
66
  stop(): void;
67
+ /** Crash/restart must not leave Hermes running against a dead socket. */
68
+ private abortLiveTurns;
67
69
  private mintSession;
68
70
  /** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
69
71
  * connect and whenever a grant changes. Best-effort: on failure we keep the
@@ -96,6 +98,13 @@ export declare class BotRunner {
96
98
  private ownerIsChannelMember;
97
99
  private run;
98
100
  private connectOnce;
101
+ /**
102
+ * A bridge restart mid-stream leaves `{streaming:true}` bubbles with a live
103
+ * caret forever. On connect, finalize our own orphan streams and tell the
104
+ * human we got cut off — Telegram never sits on a typing caret for hours.
105
+ */
106
+ private settleAllOrphans;
107
+ private settleOrphanStreams;
99
108
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
100
109
  private scheduleRefresh;
101
110
  private reconnect;
package/dist/bot.js CHANGED
@@ -8,7 +8,7 @@ import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
8
8
  import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
9
9
  import { FileStore } from "./storage.js";
10
10
  import { TurnPublisher } from "./tool-progress.js";
11
- import { ChannelTurn, coalesceFollowups, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
11
+ import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
12
12
  import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
13
13
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
14
  const MAX_BACKOFF_MS = 60_000;
@@ -116,6 +116,7 @@ export class BotRunner {
116
116
  if (this.stopped)
117
117
  return; // truly stopped (unpair) — never resurrect
118
118
  this.lastForcedRestart = Date.now();
119
+ this.abortLiveTurns();
119
120
  if (this.refreshTimer)
120
121
  clearTimeout(this.refreshTimer);
121
122
  this.refreshTimer = null;
@@ -143,6 +144,7 @@ export class BotRunner {
143
144
  }
144
145
  stop() {
145
146
  this.stopped = true;
147
+ this.abortLiveTurns();
146
148
  if (this.refreshTimer)
147
149
  clearTimeout(this.refreshTimer);
148
150
  try {
@@ -154,6 +156,13 @@ export class BotRunner {
154
156
  this.np = null;
155
157
  this.connected = false;
156
158
  }
159
+ /** Crash/restart must not leave Hermes running against a dead socket. */
160
+ abortLiveTurns() {
161
+ for (const turn of this.turns.values()) {
162
+ if (turn.active)
163
+ turn.abort.abort();
164
+ }
165
+ }
157
166
  async mintSession() {
158
167
  const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/runtime-session`, {
159
168
  method: "POST",
@@ -378,6 +387,10 @@ export class BotRunner {
378
387
  np.on("member.joined", onRosterChange);
379
388
  np.on("member.left", onRosterChange);
380
389
  np.on("message", ((m) => {
390
+ // Own stream() placeholders echo back as message.new. Never interrupt
391
+ // or answer those — that loop is what left "…" edited bubbles.
392
+ if (isBotEcho(m, this.info.userId))
393
+ return;
381
394
  const live = this.turns.get(m.channelId);
382
395
  if (live?.active) {
383
396
  live.interrupt({ id: m.messageId, message: m });
@@ -397,6 +410,68 @@ export class BotRunner {
397
410
  }));
398
411
  await this.refreshAccess(); // load the allow list before we answer anyone
399
412
  this.scheduleRefresh(session.expiresAt);
413
+ void this.settleAllOrphans();
414
+ }
415
+ /**
416
+ * A bridge restart mid-stream leaves `{streaming:true}` bubbles with a live
417
+ * caret forever. On connect, finalize our own orphan streams and tell the
418
+ * human we got cut off — Telegram never sits on a typing caret for hours.
419
+ */
420
+ async settleAllOrphans() {
421
+ if (!this.np || this.stopped)
422
+ return;
423
+ let channels;
424
+ try {
425
+ channels = await this.np.channels.list();
426
+ }
427
+ catch (err) {
428
+ this.logErr(`orphan scan list failed: ${err.message}`);
429
+ return;
430
+ }
431
+ for (const ch of channels) {
432
+ if (this.stopped)
433
+ return;
434
+ if (this.turns.get(ch.channelId)?.active)
435
+ continue;
436
+ try {
437
+ const n = await this.settleOrphanStreams(ch);
438
+ if (n > 0) {
439
+ this.log(`${ch.channelId} finalized ${n} orphan stream(s)`);
440
+ await ch.send({
441
+ text: "I got cut off mid-turn. Send another message and I'll pick up.",
442
+ }).catch((err) => this.notePostFailure(ch.channelId, err));
443
+ }
444
+ }
445
+ catch (err) {
446
+ this.logErr(`orphan settle ${ch.channelId}: ${err.message}`);
447
+ }
448
+ }
449
+ }
450
+ async settleOrphanStreams(ch) {
451
+ try {
452
+ await ch.ensureKey();
453
+ }
454
+ catch {
455
+ return 0;
456
+ }
457
+ const recent = await ch.history({ limit: 40 });
458
+ let n = 0;
459
+ for (const m of recent) {
460
+ if (m.senderUserId !== this.info.userId)
461
+ continue;
462
+ const body = m.body;
463
+ if (!body || body.streaming !== true)
464
+ continue;
465
+ const text = typeof body.text === "string" ? body.text : "";
466
+ if (!text.trim() || isPlaceholderText(text)) {
467
+ await ch.recall(m.messageId).catch(() => { });
468
+ }
469
+ else {
470
+ await ch.edit(m.messageId, { text, streaming: false }).catch(() => { });
471
+ }
472
+ n += 1;
473
+ }
474
+ return n;
400
475
  }
401
476
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
402
477
  scheduleRefresh(expiresAt) {
@@ -731,7 +806,7 @@ export class BotRunner {
731
806
  };
732
807
  }
733
808
  async ingestFollowup(m) {
734
- if (m.senderUserId === this.info.userId)
809
+ if (isBotEcho(m, this.info.userId))
735
810
  return null;
736
811
  if (this.cantPost.has(m.channelId))
737
812
  return null;
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.14";
2
+ export declare const VERSION = "0.7.22";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -19,7 +19,7 @@ import { reportCapabilities } from "./capabilities.js";
19
19
  import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
20
20
  import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
21
21
  import { isBrainBackend } from "./config.js";
22
- export const VERSION = "0.7.14";
22
+ export const VERSION = "0.7.22";
23
23
  function hermesApiStatus(cfg) {
24
24
  const api = lastHermesApiHealth();
25
25
  return {
@@ -56,7 +56,7 @@ export function hasInboundWork(body) {
56
56
  const b = body;
57
57
  if (isControlType(b.type))
58
58
  return false;
59
- if (typeof b.text === "string" && b.text.trim())
59
+ if (typeof b.text === "string" && b.text.trim() && b.text.trim() !== "…" && b.text.trim() !== "...")
60
60
  return true;
61
61
  if (parseAttachments(body).length > 0)
62
62
  return true;
@@ -4,6 +4,21 @@ export interface FollowupDecision {
4
4
  text: string;
5
5
  }
6
6
  export declare function isStopRequest(text: string): boolean;
7
+ /** Bare stream() placeholder. Must never count as a user follow-up. */
8
+ export declare function isPlaceholderText(text: string): boolean;
9
+ /**
10
+ * Own stream() posts come back as message.new on the same socket. If we treat
11
+ * those as mid-turn interrupts, the bot aborts itself on every "…" bubble,
12
+ * Hermes HTTP returns empty, CLI fallback starts a second job, and the chat
13
+ * fills with edited dots. Telegram never sees the bot's own outbound messages.
14
+ */
15
+ export declare function isBotEcho(m: {
16
+ senderUserId?: string;
17
+ body?: {
18
+ text?: unknown;
19
+ streaming?: unknown;
20
+ } | null;
21
+ }, botUserId: string): boolean;
7
22
  export declare function coalesceFollowups(texts: string[]): FollowupDecision;
8
23
  export declare function wrapInterruptedFollowup(text: string): string;
9
24
  export declare function hermesSessionId(channelId: string): string;
package/dist/mid-turn.js CHANGED
@@ -11,6 +11,27 @@ const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
11
11
  export function isStopRequest(text) {
12
12
  return STOP_RE.test((text || "").trim());
13
13
  }
14
+ /** Bare stream() placeholder. Must never count as a user follow-up. */
15
+ export function isPlaceholderText(text) {
16
+ const t = (text || "").trim();
17
+ return t === "…" || t === "...";
18
+ }
19
+ /**
20
+ * Own stream() posts come back as message.new on the same socket. If we treat
21
+ * those as mid-turn interrupts, the bot aborts itself on every "…" bubble,
22
+ * Hermes HTTP returns empty, CLI fallback starts a second job, and the chat
23
+ * fills with edited dots. Telegram never sees the bot's own outbound messages.
24
+ */
25
+ export function isBotEcho(m, botUserId) {
26
+ if (botUserId && m.senderUserId === botUserId)
27
+ return true;
28
+ const body = m.body;
29
+ if (body && body.streaming === true)
30
+ return true;
31
+ if (typeof body?.text === "string" && isPlaceholderText(body.text))
32
+ return true;
33
+ return false;
34
+ }
14
35
  export function coalesceFollowups(texts) {
15
36
  const cleaned = texts.map((t) => (t || "").trim()).filter(Boolean);
16
37
  if (cleaned.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.20",
3
+ "version": "0.7.22",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",