mercury-agent 0.16.0 → 0.16.1

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.
@@ -95,7 +95,12 @@ RUN bun install --production
95
95
  # but before the volatile /app source COPYs below — so this expensive `chown -R`
96
96
  # (it walks the whole Chromium + .bun tree) lands in a stable cached layer.
97
97
  # Editing source files no longer invalidates it or forces the huge layer re-export.
98
- RUN chown -R mercury:mercury /home/mercury
98
+ # `mkdir` here rather than leaving it to Docker: the host mounts the global dir
99
+ # into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
100
+ # the mount parent and own it as root — and pi 0.84's credential store writes
101
+ # an empty auth.json on its first *read*, which then fails EACCES and takes
102
+ # every model leg down with it. Folded into the chown so it shares the layer.
103
+ RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
99
104
 
100
105
  COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
101
106
  COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
@@ -94,7 +94,12 @@ RUN echo '#!/bin/sh\nbun run /app/src/cli/mrctl.ts "$@"' > /usr/local/bin/mrctl
94
94
  chmod +x /usr/local/bin/mrctl
95
95
 
96
96
  # Fix ownership of all mercury home dir artifacts before switching user
97
- RUN chown -R mercury:mercury /home/mercury
97
+ # `mkdir` here rather than leaving it to Docker: the host mounts the global dir
98
+ # into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
99
+ # the mount parent and own it as root — and pi 0.84's credential store writes
100
+ # an empty auth.json on its first *read*, which then fails EACCES and takes
101
+ # every model leg down with it. Folded into the chown so it shares the layer.
102
+ RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
98
103
 
99
104
  USER mercury
100
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -421,18 +421,28 @@ export class WhatsAppBaileysAdapter
421
421
  }
422
422
 
423
423
  if (!this.connected || !this.sock) {
424
- this.outgoingQueue.push({ jid: chatJid, text });
425
- logger.warn("WhatsApp queued outbound", {
426
- chatJid,
427
- queueSize: this.outgoingQueue.length,
428
- });
429
- return { id: `queued-${Date.now()}`, threadId, raw: {} };
424
+ return this.queueOutbound(threadId, chatJid, text, "not connected");
430
425
  }
431
426
 
432
427
  logger.info("WhatsApp outbound", { chatJid, preview: text.slice(0, 120) });
433
- const sent = await this.sock.sendMessage(chatJid, {
434
- text: applyRtlDirection(normalizeChatMarkdown(text)),
435
- });
428
+ let sent: WAMessage | undefined;
429
+ try {
430
+ sent = await this.sock.sendMessage(chatJid, {
431
+ text: applyRtlDirection(normalizeChatMarkdown(text)),
432
+ });
433
+ } catch (err) {
434
+ // The `connected` check above is a pre-flight guard, not a failure
435
+ // handler: `connection.update` fires asynchronously, so a socket that
436
+ // dies mid-send throws here with the flag still true. Queueing on the
437
+ // throw is what actually survives a drop — without it the reply is lost
438
+ // for good, and the caller's dangling ⏳ status message never clears.
439
+ return this.queueOutbound(
440
+ threadId,
441
+ chatJid,
442
+ text,
443
+ err instanceof Error ? err.message : String(err),
444
+ );
445
+ }
436
446
  if (!sent) {
437
447
  throw new Error("WhatsApp sendMessage returned no message");
438
448
  }
@@ -443,6 +453,26 @@ export class WhatsAppBaileysAdapter
443
453
  };
444
454
  }
445
455
 
456
+ /**
457
+ * Park an outbound text for `flushOutgoingQueue` to replay once the socket
458
+ * reopens. Returns a synthetic id: the send has not happened yet, so there
459
+ * is no platform message id to record against it.
460
+ */
461
+ private queueOutbound(
462
+ threadId: string,
463
+ chatJid: string,
464
+ text: string,
465
+ reason: string,
466
+ ): RawMessage<proto.IWebMessageInfo> {
467
+ this.outgoingQueue.push({ jid: chatJid, text });
468
+ logger.warn("WhatsApp queued outbound", {
469
+ chatJid,
470
+ reason,
471
+ queueSize: this.outgoingQueue.length,
472
+ });
473
+ return { id: `queued-${Date.now()}`, threadId, raw: {} };
474
+ }
475
+
446
476
  async editMessage(
447
477
  _threadId: string,
448
478
  _messageId: string,
@@ -762,9 +792,22 @@ export class WhatsAppBaileysAdapter
762
792
  while (this.outgoingQueue.length > 0) {
763
793
  const item = this.outgoingQueue.shift();
764
794
  if (!item) continue;
765
- await this.sock.sendMessage(item.jid, {
766
- text: applyRtlDirection(normalizeChatMarkdown(item.text)),
767
- });
795
+ try {
796
+ await this.sock.sendMessage(item.jid, {
797
+ text: applyRtlDirection(normalizeChatMarkdown(item.text)),
798
+ });
799
+ } catch (err) {
800
+ // Per item, not per flush: an unsendable message (or a socket that
801
+ // dropped again mid-drain) must not discard everything still queued
802
+ // behind it. The item itself is already shifted off — dropping one
803
+ // is the cost of not blocking the rest, and it is logged.
804
+ logger.error("WhatsApp queued outbound failed, dropping", {
805
+ chatJid: item.jid,
806
+ remaining: this.outgoingQueue.length,
807
+ error: err instanceof Error ? err.message : String(err),
808
+ });
809
+ if (!this.connected || !this.sock) return;
810
+ }
768
811
  }
769
812
  } finally {
770
813
  this.flushing = false;
@@ -297,10 +297,12 @@ export class WhatsAppBridge implements PlatformBridge {
297
297
  }
298
298
 
299
299
  if (!textSent) {
300
- const sent = await sock.sendMessage(chatJid, {
301
- text: applyRtlDirection(normalizeChatMarkdown(text)),
302
- });
303
- if (sent?.key?.id) lastSentId = sent.key.id;
300
+ // Through the adapter, not the raw socket: `postMessage` owns the
301
+ // queue-and-flush-on-reconnect path, and reaching around it is what let
302
+ // a mid-run disconnect destroy the whole reply — text included — while
303
+ // text-only replies in other spaces survived the same drop.
304
+ const sent = await this.adapter.postMessage(threadId, text);
305
+ if (sent.id) lastSentId = sent.id;
304
306
  }
305
307
 
306
308
  return lastSentId;
@@ -185,11 +185,44 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
185
185
  ? `${reply}\n\n_(responded in ${elapsed}s)_`
186
186
  : reply;
187
187
  if (finalReply || files.length > 0) {
188
- const sentPlatformId = await bridge.sendReply(
189
- threadId,
190
- finalReply,
191
- files.length > 0 ? files : undefined,
192
- );
188
+ // Guarded, because this await used to be the one unprotected step
189
+ // between a finished run and the user: the try/catch above covers
190
+ // `handleRawInput` only, so a delivery throw escaped `processIngress`
191
+ // entirely and surfaced as a bare "Debounce flush error" — with the ⏳
192
+ // status message left dangling (its best-effort delete had already
193
+ // failed against the same dead socket). The run is done and the reply
194
+ // is already stored; losing the transport must not read as "still
195
+ // thinking".
196
+ let sentPlatformId: string | undefined;
197
+ try {
198
+ sentPlatformId = await bridge.sendReply(
199
+ threadId,
200
+ finalReply,
201
+ files.length > 0 ? files : undefined,
202
+ );
203
+ } catch (err) {
204
+ logger.error("Failed to deliver reply", {
205
+ platform: bridge.platform,
206
+ threadId,
207
+ spaceId: ingress.spaceId,
208
+ fileCount: files.length,
209
+ error:
210
+ err instanceof Error ? (err.stack ?? err.message) : String(err),
211
+ });
212
+ // Retry the text alone. On WhatsApp this lands in the adapter's
213
+ // outbound queue and is replayed on reconnect, so the human gets the
214
+ // answer even when the attachments are gone; the files stay in
215
+ // `outbox/` for retrieval.
216
+ if (finalReply) {
217
+ await bridge.sendReply(threadId, finalReply).catch((e) =>
218
+ logger.error("Failed to deliver reply text after retry", {
219
+ threadId,
220
+ error: e instanceof Error ? e.message : String(e),
221
+ }),
222
+ );
223
+ }
224
+ return;
225
+ }
193
226
 
194
227
  if (
195
228
  sentPlatformId &&