@nopeek/agent-bridge 0.7.21 → 0.7.23

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
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
18
18
  import { basename, join } from "node:path";
19
19
  import { asHooks, stripAnsi } from "./brain.js";
20
20
  import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
21
+ import { parseHermesActivityLine } from "./tool-progress.js";
21
22
  const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
22
23
  // ------------------------------------------------------------------ souls ----
23
24
  /** Built-in personality template — personalized per bot by provisionSoul(). */
@@ -347,7 +348,15 @@ export function provisionHermesProfile(handle) {
347
348
  }
348
349
  // Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
349
350
  // bubble (● …), the goodbye line, and session-info footers.
350
- const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session(_| )?id:/i, /^↻/];
351
+ const HERMES_CHROME = [
352
+ /^[─━—-]{4,}\s*─*$/,
353
+ /^●/,
354
+ /^Goodbye!/,
355
+ /^session(_| )?id:/i,
356
+ /^↻/,
357
+ /^[╭╰┌┐└┘]/,
358
+ /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/,
359
+ ];
351
360
  function isHermesChrome(line) {
352
361
  return HERMES_CHROME.some((re) => re.test(line));
353
362
  }
@@ -393,15 +402,17 @@ function detectHermesProfileMode(bin) {
393
402
  return hermesProfileMode;
394
403
  }
395
404
  function hermesCliBrain(cfg) {
396
- const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
405
+ const runOnce = (profile, text, sessionName, tag, onChunk, onTool) => new Promise((resolvePromise) => {
397
406
  const bin = resolveBin("hermes", "HERMES_BIN");
398
407
  const mode = detectHermesProfileMode(bin);
399
408
  // v0.14+ takes the profile NAME via env; legacy took the PATH via flag.
400
409
  const profileName = basename(profile);
401
- const args = mode === "flag" ? ["--profile", profile, "chat", "-Q"] : ["chat", "-Q"];
410
+ // Do NOT pass -Q. Quiet mode hides tool previews, so NoPeek looks dead
411
+ // while Hermes works. Telegram publishes those lines; we do the same.
412
+ const args = mode === "flag" ? ["--profile", profile, "chat"] : ["chat"];
402
413
  if (sessionName)
403
414
  args.push("--continue", sessionName);
404
- args.push("-q", text);
415
+ args.push("--accept-hooks", "--source", "nopeek", "-q", text);
405
416
  const child = spawn(bin, args, {
406
417
  stdio: ["ignore", "pipe", "pipe"],
407
418
  // Hermes keys everything off $HOME; point it at the Hermes install.
@@ -452,6 +463,12 @@ function hermesCliBrain(cfg) {
452
463
  }
453
464
  if (noSession)
454
465
  return; // swallow the "Use 'hermes sessions list'…" tail too
466
+ const activity = parseHermesActivityLine(line);
467
+ if (activity) {
468
+ sawContent = true;
469
+ onTool?.(activity);
470
+ return;
471
+ }
455
472
  sawContent = true;
456
473
  out += `${line}\n`;
457
474
  onChunk?.(`${line}\n`);
@@ -535,7 +552,7 @@ function hermesCliBrain(cfg) {
535
552
  });
536
553
  });
537
554
  return async (text, ctx, hooks) => {
538
- const { onChunk } = asHooks(hooks);
555
+ const { onChunk, onTool } = asHooks(hooks);
539
556
  const handle = ctx.botHandle.replace(/^@/, "");
540
557
  const tag = `[brain:hermes:@${handle}]`;
541
558
  if (!resolveBin("hermes", "HERMES_BIN")) {
@@ -551,12 +568,12 @@ function hermesCliBrain(cfg) {
551
568
  return BRAIN_UNREACHABLE;
552
569
  }
553
570
  const sessionName = `nopeek-${ctx.channelId}`;
554
- let run = await runOnce(profile, text, sessionName, tag, onChunk);
571
+ let run = await runOnce(profile, text, sessionName, tag, onChunk, onTool);
555
572
  if (run.noSession && !run.reply) {
556
573
  // First message in this channel: the named session doesn't exist yet.
557
574
  // Start fresh, then name the new session so the NEXT message continues it.
558
575
  console.log(`${tag} no session ${sessionName} yet — starting fresh`);
559
- run = await runOnce(profile, text, null, tag, onChunk);
576
+ run = await runOnce(profile, text, null, tag, onChunk, onTool);
560
577
  if (run.sessionId) {
561
578
  const bin = resolveBin("hermes", "HERMES_BIN");
562
579
  const mode = detectHermesProfileMode(bin);
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, isBotEcho, 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",
@@ -401,6 +410,68 @@ export class BotRunner {
401
410
  }));
402
411
  await this.refreshAccess(); // load the allow list before we answer anyone
403
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;
404
475
  }
405
476
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
406
477
  scheduleRefresh(expiresAt) {
@@ -624,7 +695,16 @@ export class BotRunner {
624
695
  replace: (full) => s.replace(full),
625
696
  done: (final) => s.done(final),
626
697
  fail: (text) => s.fail(text),
627
- cancel: () => ch.recall(s.messageId),
698
+ cancel: async () => {
699
+ // Do not recall() — that leaves "This message was deleted" / Message recalled.
700
+ try {
701
+ s.replace("…");
702
+ await s.done("…");
703
+ }
704
+ catch {
705
+ /* best-effort drop */
706
+ }
707
+ },
628
708
  };
629
709
  },
630
710
  send: async (body) => {
@@ -637,6 +717,7 @@ export class BotRunner {
637
717
  this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
638
718
  },
639
719
  });
720
+ published.markStarted();
640
721
  const live = new ChannelTurn();
641
722
  this.turns.set(channelId, live);
642
723
  const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
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.21";
2
+ export declare const VERSION = "0.7.23";
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.21";
22
+ export const VERSION = "0.7.23";
23
23
  function hermesApiStatus(cfg) {
24
24
  const api = lastHermesApiHealth();
25
25
  return {
@@ -4,6 +4,13 @@ export type ToolProgressEvent = {
4
4
  label?: string;
5
5
  status?: string;
6
6
  };
7
+ /** Shown the moment a turn starts so the chat is never silent like Telegram. */
8
+ export declare const WORKING_LINE = "Working\u2026";
9
+ /**
10
+ * Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
11
+ * publish the same Telegram-style progress as the HTTP SSE path.
12
+ */
13
+ export declare function parseHermesActivityLine(raw: string): ToolProgressEvent | null;
7
14
  /** One Telegram-style line. Running events only; completed is silent. */
8
15
  export declare function formatToolLine(ev: ToolProgressEvent): string | null;
9
16
  /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
@@ -66,11 +73,14 @@ export declare class TurnPublisher {
66
73
  private startTimer;
67
74
  private gapTimer;
68
75
  private seen;
76
+ private started;
69
77
  private readonly startDelayMs;
70
78
  private readonly maxProgressLines;
71
79
  private readonly maxBubbleChars;
72
80
  private readonly bubbleGapMs;
73
81
  constructor(sink: TurnSink, opts: TurnPublisherOpts);
82
+ /** Open a live bubble immediately so HTTP and CLI turns never look dead. */
83
+ markStarted(): void;
74
84
  onChunk(delta: string): void;
75
85
  onTool(ev: ToolProgressEvent): void;
76
86
  finish(reply: string): Promise<TurnFinishKind>;
@@ -27,6 +27,52 @@ const FALLBACK_EMOJI = {
27
27
  cronjob: "⏰",
28
28
  process: "⚙️",
29
29
  };
30
+ /** Shown the moment a turn starts so the chat is never silent like Telegram. */
31
+ export const WORKING_LINE = "Working…";
32
+ const CLI_ACTIVITY = {
33
+ plan: { tool: "todo", emoji: "📋" },
34
+ read: { tool: "read_file", emoji: "📖" },
35
+ write: { tool: "write_file", emoji: "✍️" },
36
+ patch: { tool: "patch", emoji: "🔧" },
37
+ grep: { tool: "search_files", emoji: "🔎" },
38
+ find: { tool: "search_files", emoji: "🔎" },
39
+ $: { tool: "terminal", emoji: "💻" },
40
+ fetch: { tool: "web_extract", emoji: "📄" },
41
+ crawl: { tool: "web_crawl", emoji: "🕸️" },
42
+ memory: { tool: "memory", emoji: "🧠" },
43
+ recall: { tool: "session_search", emoji: "🔍" },
44
+ proc: { tool: "process", emoji: "⚙️" },
45
+ navigate: { tool: "browser_navigate", emoji: "🌐" },
46
+ snapshot: { tool: "browser_snapshot", emoji: "📸" },
47
+ };
48
+ /**
49
+ * Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
50
+ * publish the same Telegram-style progress as the HTTP SSE path.
51
+ */
52
+ export function parseHermesActivityLine(raw) {
53
+ const t = (raw || "").trim();
54
+ if (!t || !/^[┊│|]/.test(t))
55
+ return null;
56
+ const rest = t.replace(/^[┊│|]\s*/, "");
57
+ if (!rest || /^review\s+diff\b/i.test(rest))
58
+ return null;
59
+ const m = rest.match(/^(?:(\S+)\s+)?(\$|[A-Za-z_][\w.]*)\s*(.*)$/);
60
+ if (!m)
61
+ return null;
62
+ const maybeEmoji = m[1] || "";
63
+ const verb = (m[2] || "").replace(/:+$/, "");
64
+ if (!verb)
65
+ return null;
66
+ let label = (m[3] || "").replace(/\s+\d+(?:\.\d+)?s\s*$/, "").trim();
67
+ const mapped = CLI_ACTIVITY[verb];
68
+ const emoji = mapped?.emoji || (/^\p{Extended_Pictographic}/u.test(maybeEmoji) ? maybeEmoji : "⚡");
69
+ return {
70
+ tool: mapped?.tool || verb,
71
+ emoji,
72
+ ...(label ? { label } : {}),
73
+ status: "running",
74
+ };
75
+ }
30
76
  /** One Telegram-style line. Running events only; completed is silent. */
31
77
  export function formatToolLine(ev) {
32
78
  if (ev.status && ev.status !== "running")
@@ -261,6 +307,7 @@ export class TurnPublisher {
261
307
  startTimer = null;
262
308
  gapTimer = null;
263
309
  seen = new Set();
310
+ started = false;
264
311
  startDelayMs;
265
312
  maxProgressLines;
266
313
  maxBubbleChars;
@@ -273,6 +320,13 @@ export class TurnPublisher {
273
320
  this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
274
321
  this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
275
322
  }
323
+ /** Open a live bubble immediately so HTTP and CLI turns never look dead. */
324
+ markStarted() {
325
+ if (this.started || this.toolsUsed || this.textP || this.progressP)
326
+ return;
327
+ this.started = true;
328
+ this.enqueue(() => this.addProgressLine(WORKING_LINE));
329
+ }
276
330
  onChunk(delta) {
277
331
  const chunk = stripDumps(delta);
278
332
  if (!chunk)
@@ -317,6 +371,9 @@ export class TurnPublisher {
317
371
  const pending = this.held;
318
372
  this.held = "";
319
373
  await this.commitText(pending);
374
+ if (this.progressLines.length === 1 && this.progressLines[0] === WORKING_LINE) {
375
+ this.progressLines = [];
376
+ }
320
377
  }
321
378
  await this.addProgressLine(line);
322
379
  });
@@ -531,7 +588,7 @@ export class TurnPublisher {
531
588
  this.progressLines = [];
532
589
  if (!s)
533
590
  return;
534
- if (text)
591
+ if (text && text !== WORKING_LINE)
535
592
  await s.done(text).catch(() => { });
536
593
  else
537
594
  await s.cancel().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
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",