@nopeek/agent-bridge 0.5.4 → 0.5.6

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.
@@ -33,9 +33,4 @@ export declare function hermesHome(): string;
33
33
  * stays authenticated from one login). Idempotent.
34
34
  */
35
35
  export declare function provisionHermesProfile(handle: string): string;
36
- /**
37
- * Hermes backend: per-bot profile (own soul + memories), one persistent
38
- * session per channel (`--continue nopeek-<channel>`), streaming cleaned
39
- * stdout lines to onChunk.
40
- */
41
36
  export declare function hermesBrain(cfg: BridgeConfig): Brain;
package/dist/backends.js CHANGED
@@ -292,7 +292,7 @@ export function provisionHermesProfile(handle) {
292
292
  }
293
293
  // Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
294
294
  // bubble (● …), the goodbye line, and session-info footers.
295
- const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session( id)?:/i];
295
+ const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session(_| )?id:/i, /^↻/];
296
296
  function isHermesChrome(line) {
297
297
  return HERMES_CHROME.some((re) => re.test(line));
298
298
  }
@@ -301,26 +301,20 @@ function isHermesChrome(line) {
301
301
  * session per channel (`--continue nopeek-<channel>`), streaming cleaned
302
302
  * stdout lines to onChunk.
303
303
  */
304
+ /** `hermes chat --continue <name>` FAILS (does not create) when the named
305
+ * session doesn't exist yet — this is what its error looks like. */
306
+ const HERMES_NO_SESSION = /^No session found matching/i;
307
+ /** Session footer Hermes prints on exit; we filter it as chrome but capture
308
+ * the id so a fresh session can be renamed for future --continue. */
309
+ const HERMES_SESSION_ID = /^session(_| )?id:\s*(\S+)/i;
304
310
  export function hermesBrain(cfg) {
305
- return (text, ctx, onChunk) => new Promise((resolvePromise) => {
306
- const handle = ctx.botHandle.replace(/^@/, "");
307
- const tag = `[brain:hermes:@${handle}]`;
311
+ const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
308
312
  const bin = resolveBin("hermes", "HERMES_BIN");
309
- if (!bin) {
310
- console.error(`${tag} hermes not found on PATH`);
311
- resolvePromise(BRAIN_UNREACHABLE);
312
- return;
313
- }
314
- let profile;
315
- try {
316
- profile = provisionHermesProfile(handle);
317
- }
318
- catch (err) {
319
- console.error(`${tag} profile provisioning failed: ${err.message}`);
320
- resolvePromise(BRAIN_UNREACHABLE);
321
- return;
322
- }
323
- const child = spawn(bin, ["--profile", profile, "chat", "-Q", "--continue", `nopeek-${ctx.channelId}`, "-q", text], {
313
+ const args = ["--profile", profile, "chat", "-Q"];
314
+ if (sessionName)
315
+ args.push("--continue", sessionName);
316
+ args.push("-q", text);
317
+ const child = spawn(bin, args, {
324
318
  stdio: ["ignore", "pipe", "pipe"],
325
319
  // Hermes keys everything off $HOME; point it at the Hermes install.
326
320
  env: { ...process.env, HOME: hermesHome() },
@@ -330,12 +324,42 @@ export function hermesBrain(cfg) {
330
324
  let sawContent = false; // suppress leading blank lines
331
325
  let stderr = "";
332
326
  let settled = false;
327
+ let noSession = false;
328
+ let sessionId = null;
329
+ let bannerCont = false; // inside a wrapped "↻ Resumed session …" banner
333
330
  const handleLine = (raw) => {
334
331
  const line = stripAnsi(raw);
332
+ const sid = HERMES_SESSION_ID.exec(line);
333
+ if (sid)
334
+ sessionId = sid[2];
335
+ // The resume banner ("↻ Resumed session … (1 message, 2 total messages)")
336
+ // can wrap across lines; swallow continuations until the closing paren.
337
+ if (bannerCont) {
338
+ if (/\)\s*$/.test(line) || !line.trim())
339
+ bannerCont = false;
340
+ return;
341
+ }
342
+ if (/^↻/.test(line.trim())) {
343
+ if (!/\)\s*$/.test(line.trim()))
344
+ bannerCont = true;
345
+ return;
346
+ }
335
347
  if (isHermesChrome(line))
336
348
  return;
337
349
  if (!sawContent && !line.trim())
338
350
  return;
351
+ // Leading host warnings ("⚠ tirith security scanner…") are chrome,
352
+ // not reply. Only before real content — a ⚠ mid-reply is kept.
353
+ if (!sawContent && /^[⚠✗]/.test(line.trim()))
354
+ return;
355
+ // First content line saying "No session found" = the --continue miss,
356
+ // not a reply. Don't stream it to the chat; the caller retries fresh.
357
+ if (!sawContent && HERMES_NO_SESSION.test(line.trim())) {
358
+ noSession = true;
359
+ return;
360
+ }
361
+ if (noSession)
362
+ return; // swallow the "Use 'hermes sessions list'…" tail too
339
363
  sawContent = true;
340
364
  out += `${line}\n`;
341
365
  onChunk?.(`${line}\n`);
@@ -346,14 +370,18 @@ export function hermesBrain(cfg) {
346
370
  settled = true;
347
371
  if (lineBuf)
348
372
  handleLine(lineBuf);
349
- const reply = out.trim();
350
- if (!reply) {
351
- if (stderr.trim())
352
- console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
353
- resolvePromise("⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.");
354
- return;
373
+ // The session footer lands on STDERR in -Q mode — scan there too.
374
+ if (!sessionId) {
375
+ for (const line of stderr.split("\n")) {
376
+ const sid = HERMES_SESSION_ID.exec(stripAnsi(line).trim());
377
+ if (sid)
378
+ sessionId = sid[2];
379
+ }
355
380
  }
356
- resolvePromise(reply);
381
+ // "No session found" can land on stderr in some hermes builds.
382
+ if (!out.trim() && HERMES_NO_SESSION.test(stderr.trim()))
383
+ noSession = true;
384
+ resolvePromise({ reply: out.trim(), noSession, sessionId, stderr });
357
385
  };
358
386
  const timer = setTimeout(() => {
359
387
  console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
@@ -373,7 +401,7 @@ export function hermesBrain(cfg) {
373
401
  console.error(`${tag} spawn error: ${err.message}`);
374
402
  if (!settled) {
375
403
  settled = true;
376
- resolvePromise(BRAIN_UNREACHABLE);
404
+ resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message });
377
405
  }
378
406
  });
379
407
  child.on("close", () => {
@@ -381,4 +409,47 @@ export function hermesBrain(cfg) {
381
409
  finish();
382
410
  });
383
411
  });
412
+ return async (text, ctx, onChunk) => {
413
+ const handle = ctx.botHandle.replace(/^@/, "");
414
+ const tag = `[brain:hermes:@${handle}]`;
415
+ if (!resolveBin("hermes", "HERMES_BIN")) {
416
+ console.error(`${tag} hermes not found on PATH`);
417
+ return BRAIN_UNREACHABLE;
418
+ }
419
+ let profile;
420
+ try {
421
+ profile = provisionHermesProfile(handle);
422
+ }
423
+ catch (err) {
424
+ console.error(`${tag} profile provisioning failed: ${err.message}`);
425
+ return BRAIN_UNREACHABLE;
426
+ }
427
+ const sessionName = `nopeek-${ctx.channelId}`;
428
+ let run = await runOnce(profile, text, sessionName, tag, onChunk);
429
+ if (run.noSession && !run.reply) {
430
+ // First message in this channel: the named session doesn't exist yet.
431
+ // Start fresh, then name the new session so the NEXT message continues it.
432
+ console.log(`${tag} no session ${sessionName} yet — starting fresh`);
433
+ run = await runOnce(profile, text, null, tag, onChunk);
434
+ if (run.sessionId) {
435
+ const bin = resolveBin("hermes", "HERMES_BIN");
436
+ const rn = spawn(bin, ["--profile", profile, "sessions", "rename", run.sessionId, sessionName], {
437
+ stdio: "ignore",
438
+ env: { ...process.env, HOME: hermesHome() },
439
+ });
440
+ rn.on("error", () => {
441
+ /* best-effort — worst case the next message starts fresh again */
442
+ });
443
+ }
444
+ else {
445
+ console.error(`${tag} fresh session id not captured — next message will start fresh again`);
446
+ }
447
+ }
448
+ if (!run.reply) {
449
+ if (run.stderr.trim())
450
+ console.error(`${tag} stderr: ${run.stderr.slice(0, 1000)}`);
451
+ return "⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.";
452
+ }
453
+ return run.reply;
454
+ };
384
455
  }
package/dist/bot.d.ts CHANGED
@@ -24,6 +24,8 @@ export declare class BotRunner {
24
24
  private allowed;
25
25
  private accessLoaded;
26
26
  private declined;
27
+ private chains;
28
+ private cantPost;
27
29
  private log;
28
30
  private logErr;
29
31
  constructor(info: BotInfo, cfg: BridgeConfig);
@@ -50,4 +52,7 @@ export declare class BotRunner {
50
52
  /** Bounded dedupe so a redelivered frame is never answered twice. */
51
53
  private remember;
52
54
  private handleMessage;
55
+ /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
56
+ * future message too — mute the channel so the brain stops running there. */
57
+ private notePostFailure;
53
58
  }
package/dist/bot.js CHANGED
@@ -28,6 +28,13 @@ export class BotRunner {
28
28
  allowed = new Set();
29
29
  accessLoaded = false;
30
30
  declined = new Set(); // (senderId) already told "not authorized" once
31
+ // Per-channel serialization: two messages in one channel must be answered in
32
+ // order, one at a time — concurrent brain runs against the same agent session
33
+ // (e.g. one Hermes session per channel) deadlock or reply out of order.
34
+ chains = new Map();
35
+ // Channels the bot can't post to (e.g. broadcast, non-operator): after the
36
+ // first FORBIDDEN, skip the brain entirely — replies there can never land.
37
+ cantPost = new Set();
31
38
  log;
32
39
  logErr;
33
40
  constructor(info, cfg) {
@@ -153,8 +160,15 @@ export class BotRunner {
153
160
  this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
154
161
  }));
155
162
  np.on("message", ((m) => {
156
- void this.handleMessage(m).catch((err) => {
163
+ const prev = this.chains.get(m.channelId) ?? Promise.resolve();
164
+ const next = prev.then(() => this.handleMessage(m).catch((err) => {
165
+ this.notePostFailure(m.channelId, err);
157
166
  this.logErr(`handler error for ${m.messageId}: ${err.message}`);
167
+ }));
168
+ this.chains.set(m.channelId, next);
169
+ void next.finally(() => {
170
+ if (this.chains.get(m.channelId) === next)
171
+ this.chains.delete(m.channelId);
158
172
  });
159
173
  }));
160
174
  await this.refreshAccess(); // load the allow list before we answer anyone
@@ -216,6 +230,8 @@ export class BotRunner {
216
230
  this.remember(m.messageId);
217
231
  if (m.senderUserId === this.info.userId)
218
232
  return; // never answer ourselves
233
+ if (this.cantPost.has(m.channelId))
234
+ return; // replies can't land here — don't burn a brain run
219
235
  if (m.decryptionFailed) {
220
236
  // Likely a missed welcome (message arrived before our key). Sync the key
221
237
  // so the NEXT message decrypts; this frame's plaintext is unrecoverable.
@@ -278,6 +294,7 @@ export class BotRunner {
278
294
  }
279
295
  streamRef.p = ch.stream();
280
296
  streamRef.p.catch((err) => {
297
+ this.notePostFailure(m.channelId, err);
281
298
  this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
282
299
  });
283
300
  }
@@ -323,4 +340,14 @@ export class BotRunner {
323
340
  this.handled++;
324
341
  this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
325
342
  }
343
+ /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
344
+ * future message too — mute the channel so the brain stops running there. */
345
+ notePostFailure(channelId, err) {
346
+ if (!/only operators can post|FORBIDDEN/i.test(err.message))
347
+ return;
348
+ if (this.cantPost.has(channelId))
349
+ return;
350
+ this.cantPost.add(channelId);
351
+ this.log(`muting ${channelId} — bot cannot post here (${err.message.slice(0, 80)})`);
352
+ }
326
353
  }
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, BrainSpec } from "./config.js";
2
- export declare const VERSION = "0.5.4";
2
+ export declare const VERSION = "0.5.6";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -14,7 +14,7 @@ import { resolveBrain } from "./brain.js";
14
14
  import { provisionSoul, provisionHermesProfile } from "./backends.js";
15
15
  import { reportCapabilities } from "./capabilities.js";
16
16
  import { isBrainBackend } from "./config.js";
17
- export const VERSION = "0.5.4";
17
+ export const VERSION = "0.5.6";
18
18
  /** How often to re-probe + report brain availability to the server. */
19
19
  const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
20
20
  export class PairError extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with a one-time code, runs every bot you own, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",