@ardennguyen/zalo-agent-cli 2.5.4 → 2.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.
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import express from "express";
7
+ import { renderPrometheus } from "../core/daemon-health.js";
8
+ import { batchParams, waitResult } from "./wait-result.js";
9
+ import { DEBUG_MARK } from "../core/log-sink.js";
7
10
  import https from "https";
8
11
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
9
12
  import { readFileSync } from "fs";
@@ -143,7 +146,8 @@ function resolveIssuerOrigin(req, configuredIssuer, isTls) {
143
146
  /**
144
147
  * Create HTTP or HTTPS MCP server with optional bearer token and OAuth 2.0 auth.
145
148
  * @param {Function} registerToolsFn - Registers tools on a McpServer instance
146
- * @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs }
149
+ * @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs, health }
150
+ * (`health` is the daemon's ./core/daemon-health.js object: behind /health, /ready, /status and /metrics)
147
151
  * @param {number} port
148
152
  * @param {string|null} authToken - Bearer token (null = no auth unless oauth.clientSecret or oauth.clients is set)
149
153
  * @param {string} [host="127.0.0.1"] - Bind address
@@ -192,7 +196,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
192
196
  const ua = String(req.headers["user-agent"] || "-").slice(0, 80);
193
197
  const h = String(req.headers.host || "-");
194
198
  console.error(
195
- `[mcp-http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (host=${h} ip=${remoteIp} ua="${ua}")`,
199
+ `${DEBUG_MARK}[mcp-http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (host=${h} ip=${remoteIp} ua="${ua}")`,
196
200
  );
197
201
  });
198
202
  }
@@ -503,7 +507,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
503
507
  // ── Bearer / OAuth2 Auth Middleware — skips /health ─────────────────
504
508
  if (signingSecret) {
505
509
  app.use((req, res, next) => {
506
- if (req.path === "/health") return next();
510
+ if (req.path === "/health" || req.path === "/ready") return next();
507
511
  const authHeader = req.headers.authorization || "";
508
512
  const token = /^bearer\s+/i.test(authHeader) ? authHeader.slice(7).trim() : "";
509
513
 
@@ -583,27 +587,19 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
583
587
  const ac = new AbortController();
584
588
  res.on("close", () => ac.abort());
585
589
 
590
+ const { limit, settleMs } = batchParams({ limit: req.query.limit, settleMs: req.query.settle });
591
+
586
592
  try {
587
- const msg = await deps.buffer.waitForMessage({
593
+ const { messages, hasMore } = await deps.buffer.waitForMessages({
588
594
  threadId,
589
595
  since,
590
596
  timeoutMs: timeoutSec * 1000,
591
597
  signal: ac.signal,
598
+ limit,
599
+ settleMs,
592
600
  });
593
601
  if (res.writableEnded) return;
594
- if (msg) {
595
- res.json({
596
- status: "message",
597
- message: msg,
598
- cursor: msg._cursor,
599
- });
600
- } else {
601
- res.json({
602
- status: "timeout",
603
- message: null,
604
- cursor: deps.buffer.readCursor(),
605
- });
606
- }
602
+ res.json(waitResult({ messages, hasMore, cursorWhenEmpty: deps.buffer.readCursor() }));
607
603
  } catch (err) {
608
604
  if (!res.headersSent) {
609
605
  res.status(500).json({ error: err.message });
@@ -611,15 +607,38 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
611
607
  }
612
608
  });
613
609
 
614
- // Health check — no auth required
610
+ // Liveness — no auth required, and deliberately thin: that the process answers, and whether its socket is
611
+ // up. Everything else is behind the token (/status, /metrics).
615
612
  app.get("/health", (req, res) => {
616
613
  res.json({
617
614
  status: "ok",
618
615
  uptime: Math.floor(process.uptime()),
619
616
  threads: deps.buffer.getStats().length,
617
+ ...(deps.health ? { socket: deps.health.ready().socket, ready: deps.health.ready().ready } : {}),
620
618
  });
621
619
  });
622
620
 
621
+ // Readiness — no auth required: 200 only while the Zalo socket is connected, so a load balancer or a
622
+ // supervisor can stop sending work to a daemon whose account is offline.
623
+ app.get("/ready", (req, res) => {
624
+ const r = deps.health ? deps.health.ready() : { ready: true, socket: "unknown" };
625
+ res.status(r.ready ? 200 : 503).json(r);
626
+ });
627
+
628
+ // Everything the daemon knows about itself (protected by the auth middleware above).
629
+ app.get("/status", (req, res) => {
630
+ if (!deps.health) return res.status(503).json({ error: "this server does not report health" });
631
+ res.json(deps.health.snapshot());
632
+ });
633
+
634
+ // The same, as Prometheus text (protected by the auth middleware above; Prometheus sends a bearer token).
635
+ app.get("/metrics", (req, res) => {
636
+ if (!deps.health) return res.status(503).type("text/plain").send("# this server does not report health\n");
637
+ res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8").send(
638
+ renderPrometheus(deps.health.snapshot()),
639
+ );
640
+ });
641
+
623
642
  if (isTls) {
624
643
  const primary = https.createServer({ cert: tls.cert, key: tls.key }, app).listen(port, host, () => {
625
644
  console.error(`MCP HTTPS server listening on ${host}:${port}`);
@@ -43,10 +43,12 @@ import { groupMemberUids, fetchMemberNames } from "../core/group-members.js";
43
43
  import { recentConversations } from "../core/recent-conversations.js";
44
44
  import { describeReadState, readOnZalo } from "../core/read-state.js";
45
45
  import { extractMessageText } from "../utils/extract-message-text.js";
46
- import { expandMentions, ALL_MENTION_UID } from "../utils/mentions.js";
46
+ import { expandMentions, shiftStyles, ALL_MENTION_UID } from "../utils/mentions.js";
47
47
  import { resolveSelfThread } from "../utils/my-documents.js";
48
48
  import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
49
49
  import { urgencyLevel } from "../utils/urgency.js";
50
+ import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
51
+ import { MAX_BATCH, MAX_SETTLE_MS, batchParams, waitResult } from "./wait-result.js";
50
52
  import {
51
53
  describeFriendAddError,
52
54
  extractErrorCode,
@@ -542,6 +544,16 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
542
544
  "msgId of a cached TEXT message in this thread to quote-reply to. " +
543
545
  "Get it from zalo_get_messages or zalo_get_history.",
544
546
  ),
547
+ format: z
548
+ .enum(["markdown", "plain"])
549
+ .optional()
550
+ .describe(
551
+ "How `text` is sent. `markdown` (the default) turns Markdown into Zalo's own formatting, because Zalo " +
552
+ "shows `**bold**` and `- item` as typed: **bold**, *italic*, ~~strike~~, # headings (bold), " +
553
+ "- bullets (real bullets), 1. lists (numbers kept), > quotes, `code`, [text](url) (the URL stays in " +
554
+ "the text), and {red:text}/{green:text}/{big:text} colors. `plain` sends the text exactly as written. " +
555
+ "Text that is not Markdown (snake_case, 2 * 3 * 4) is left alone either way.",
556
+ ),
545
557
  urgency: z
546
558
  .enum(["normal", "important", "urgent"])
547
559
  .optional()
@@ -577,6 +589,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
577
589
  threadType: threadTypeIn,
578
590
  quoteMsgId,
579
591
  urgency: urgencyIn,
592
+ format: formatIn,
580
593
  }) => {
581
594
  // `me` (or the own uid) is My Documents, always a 1-1 -- resolved
582
595
  // before anything else reads the thread id, as `msg send me` is.
@@ -625,8 +638,17 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
625
638
  if (missing.length > 0) {
626
639
  return err(`File not found: ${missing.join(", ")}`);
627
640
  }
641
+ // A caption is text too: Markdown in it is converted the same way.
642
+ const caption =
643
+ text && resolveSendFormat({ explicit: formatIn, fallback: "markdown" }) === "markdown"
644
+ ? markdownToZalo(text)
645
+ : { text: text || "", styles: [] };
628
646
  const result = await api.sendMessage(
629
- { msg: text || "", attachments: absPaths },
647
+ {
648
+ msg: caption.text,
649
+ attachments: absPaths,
650
+ ...(caption.styles.length > 0 && { styles: caption.styles }),
651
+ },
630
652
  threadId,
631
653
  Number(threadType),
632
654
  );
@@ -667,13 +689,19 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
667
689
  // order against and nothing to move — if styles are ever added to
668
690
  // this tool, they must be run through `shiftStyles(styles,
669
691
  // expanded.edits)` the way `msg send` does.
692
+ // Markdown first, then mentions: a display name may itself contain `*` or `_`, whereas an
693
+ // `@[uid]` token contains no Markdown (the converter passes it through). The style ranges
694
+ // are counted before the names go in, so they move across the substitutions (`shiftStyles`).
695
+ const format = resolveSendFormat({ explicit: formatIn, fallback: "markdown" });
696
+ const converted = format === "markdown" ? markdownToZalo(text) : { text, styles: [] };
670
697
  const unresolved = [];
671
- const expanded = expandMentions(text, (uid) => {
698
+ const expanded = expandMentions(converted.text, (uid) => {
672
699
  const name = cachedDisplayName(uid);
673
700
  if (!name && uid !== ALL_MENTION_UID) unresolved.push(uid);
674
701
  return name;
675
702
  });
676
703
  const mentions = expanded.mentions;
704
+ const styles = shiftStyles(converted.styles, expanded.edits);
677
705
 
678
706
  // zca-js drops mentions outside a group, so a DM send would
679
707
  // quietly arrive with the names as plain text and nobody tagged.
@@ -726,11 +754,12 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
726
754
 
727
755
  // A plain send stays a plain string: the object form is only
728
756
  // built when there is something to put in it.
729
- const hasExtras = mentions.length > 0 || Boolean(quote) || Boolean(urgency);
757
+ const hasExtras = mentions.length > 0 || styles.length > 0 || Boolean(quote) || Boolean(urgency);
730
758
  const content = hasExtras
731
759
  ? {
732
760
  msg: expanded.text,
733
761
  ...(mentions.length > 0 && { mentions }),
762
+ ...(styles.length > 0 && { styles }),
734
763
  ...(quote && { quote }),
735
764
  ...(urgency && { urgency }),
736
765
  }
@@ -755,6 +784,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
755
784
  // Only when it differs, so the common send keeps its shape:
756
785
  // the agent wrote `@[123]` and needs to know what was sent.
757
786
  ...(expanded.text !== text && { text: expanded.text }),
787
+ ...(styles.length > 0 && { formatted: styles.length }),
758
788
  ...(unresolved.length > 0 && { unresolvedMentions: unresolved }),
759
789
  ...(warnings.length > 0 && { warnings }),
760
790
  });
@@ -2347,7 +2377,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2347
2377
  description:
2348
2378
  "Long-poll the live MCP message buffer for the next incoming watched message without manual polling. " +
2349
2379
  "Returns immediately if a buffered message newer than `since` is already waiting; otherwise waits up to " +
2350
- "`timeoutSec` seconds (default 55, max 300) for a new message to arrive. Mirrors GET /events/wait for " +
2380
+ "`timeoutSec` seconds (default 55, max 300) for a new message to arrive. `limit` (default 1, max 100) returns " +
2381
+ "up to that many buffered messages in one call, in order, in `messages` (`message` is the first and `cursor` " +
2382
+ "the last, so `since: cursor` continues where the batch ended; `hasMore` says more are already waiting); " +
2383
+ "`settleMs` waits that long after the first for the rest of a burst. Mirrors GET /events/wait for " +
2351
2384
  "remote HTTPS/OAuth2 MCP clients.",
2352
2385
  inputSchema: z.object({
2353
2386
  threadId: z
@@ -2371,48 +2404,61 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
2371
2404
  .max(300)
2372
2405
  .default(55)
2373
2406
  .describe("Maximum seconds to wait before returning `status: 'timeout'` (default 55, max 300)"),
2407
+ limit: z
2408
+ .number()
2409
+ .int()
2410
+ .min(1)
2411
+ .max(MAX_BATCH)
2412
+ .default(1)
2413
+ .describe(`Most messages to return in one call (default 1, max ${MAX_BATCH})`),
2414
+ settleMs: z
2415
+ .number()
2416
+ .int()
2417
+ .min(0)
2418
+ .max(MAX_SETTLE_MS)
2419
+ .default(0)
2420
+ .describe(
2421
+ `After the first message arrives, wait this many ms for the rest of a burst (default 0, max ${MAX_SETTLE_MS})`,
2422
+ ),
2374
2423
  }),
2375
2424
  },
2376
- async ({ threadId, since, timeoutSec = 55 }) => {
2425
+ async ({ threadId, since, timeoutSec = 55, limit = 1, settleMs = 0 }) => {
2377
2426
  try {
2378
- if (typeof buffer?.waitForMessage !== "function") {
2379
- return err("Live message buffer does not support waitForMessage in this context.");
2427
+ if (typeof buffer?.waitForMessages !== "function") {
2428
+ return err("Live message buffer does not support waitForMessages in this context.");
2380
2429
  }
2381
2430
  const cleanThreadId = typeof threadId === "string" && threadId.trim() ? threadId.trim() : null;
2382
2431
  const clampedSec = Math.max(1, Math.min(300, Number(timeoutSec) || 55));
2383
- const msg = await buffer.waitForMessage({
2432
+ const batch = batchParams({ limit, settleMs });
2433
+ const { messages, hasMore } = await buffer.waitForMessages({
2384
2434
  threadId: cleanThreadId,
2385
2435
  since: typeof since === "number" && Number.isFinite(since) ? since : undefined,
2386
2436
  timeoutMs: clampedSec * 1000,
2437
+ ...batch,
2387
2438
  });
2388
- if (msg) {
2389
- const tid = String(msg.threadId || "");
2390
- let threadName = nameCache?.get(tid)?.name || null;
2391
- if (!threadName && tid) {
2392
- try {
2393
- threadName = getDisplayName(tid) || null;
2394
- } catch {
2395
- threadName = null;
2396
- }
2397
- }
2398
- return ok({
2399
- status: "message",
2400
- message: {
2401
- ...msg,
2402
- ...(threadName ? { threadName } : {}),
2439
+ return ok(
2440
+ waitResult({
2441
+ messages,
2442
+ hasMore,
2443
+ cursorWhenEmpty: Math.max(
2444
+ typeof since === "number" && Number.isFinite(since) ? since : 0,
2445
+ Number(buffer._globalCursor) || 0,
2446
+ typeof buffer.readCursor === "function" ? buffer.readCursor() : 0,
2447
+ ),
2448
+ decorate: (msg) => {
2449
+ const tid = String(msg.threadId || "");
2450
+ let threadName = nameCache?.get(tid)?.name || null;
2451
+ if (!threadName && tid) {
2452
+ try {
2453
+ threadName = getDisplayName(tid) || null;
2454
+ } catch {
2455
+ threadName = null;
2456
+ }
2457
+ }
2458
+ return { ...msg, ...(threadName ? { threadName } : {}) };
2403
2459
  },
2404
- cursor: msg._cursor,
2405
- });
2406
- }
2407
- return ok({
2408
- status: "timeout",
2409
- message: null,
2410
- cursor: Math.max(
2411
- typeof since === "number" && Number.isFinite(since) ? since : 0,
2412
- Number(buffer._globalCursor) || 0,
2413
- typeof buffer.readCursor === "function" ? buffer.readCursor() : 0,
2414
- ),
2415
- });
2460
+ }),
2461
+ );
2416
2462
  } catch (e) {
2417
2463
  console.error("[mcp-tools] zalo_wait_for_message error:", e.message);
2418
2464
  return err(e.message);
@@ -9,6 +9,45 @@
9
9
  * (maxSize per thread, maxAge), which bounds memory as before.
10
10
  */
11
11
 
12
+ import { batchParams } from "./wait-result.js";
13
+
14
+ /** Sleep `ms`, or less if `signal` aborts. */
15
+ function sleep(ms, signal) {
16
+ return new Promise((resolve) => {
17
+ if (signal?.aborted) return resolve();
18
+ const timer = setTimeout(done, ms);
19
+ function done() {
20
+ clearTimeout(timer);
21
+ signal?.removeEventListener("abort", done);
22
+ resolve();
23
+ }
24
+ signal?.addEventListener("abort", done, { once: true });
25
+ });
26
+ }
27
+
28
+ /** How often a settling wait looks at the buffer to see whether the batch is already full. */
29
+ const SETTLE_POLL_MS = 20;
30
+
31
+ /**
32
+ * Let a burst land: read, and keep reading for up to `settleMs`, but stop the moment `limit` messages
33
+ * are in hand (a full batch has nothing to wait for) or `signal` aborts.
34
+ *
35
+ * @param {() => { messages: object[], hasMore: boolean }} read
36
+ * @param {number} limit
37
+ * @param {number} settleMs
38
+ * @param {AbortSignal} [signal]
39
+ * @returns {Promise<{ messages: object[], hasMore: boolean }>}
40
+ */
41
+ async function settleUntilFull(read, limit, settleMs, signal) {
42
+ const deadline = Date.now() + settleMs;
43
+ let r = read();
44
+ while (r.messages.length < limit && Date.now() < deadline && !signal?.aborted) {
45
+ await sleep(Math.min(SETTLE_POLL_MS, Math.max(1, deadline - Date.now())), signal);
46
+ r = read();
47
+ }
48
+ return r;
49
+ }
50
+
12
51
  /** The consumer a caller that names none reads as. */
13
52
  export const DEFAULT_CONSUMER = "default";
14
53
 
@@ -78,13 +117,64 @@ export class MessageBuffer {
78
117
  * @param {AbortSignal} [opts.signal] - Optional abort signal (e.g. when HTTP client disconnects)
79
118
  * @returns {Promise<object|null>} The message object, or null on timeout/abort
80
119
  */
81
- waitForMessage({ threadId = null, since = undefined, timeoutMs = 60000, signal = undefined } = {}) {
120
+ async waitForMessage(opts = {}) {
121
+ const { messages } = await this.waitForMessages({ ...opts, limit: 1, settleMs: 0 });
122
+ return messages[0] ?? null;
123
+ }
124
+
125
+ /**
126
+ * Wait for buffered messages and return up to `limit` of them at once, in cursor order.
127
+ *
128
+ * With `since`, messages already buffered after it are returned at once; otherwise the call waits for
129
+ * the next message to arrive. `settleMs` then gives a burst that long to land, so the reply holds it
130
+ * whole instead of the first message alone. Nothing is waited for once `limit` is reached.
131
+ *
132
+ * @param {object} [opts]
133
+ * @param {string} [opts.threadId] - Optional thread filter
134
+ * @param {number} [opts.since] - Optional cursor (exclusive)
135
+ * @param {number} [opts.timeoutMs=60000] - Max wait for the FIRST message
136
+ * @param {AbortSignal} [opts.signal]
137
+ * @param {number} [opts.limit=1] - Most messages to return (1-100)
138
+ * @param {number} [opts.settleMs=0] - After the first message, wait this long for more (0-5000)
139
+ * @returns {Promise<{ messages: object[], hasMore: boolean }>} `messages` is empty on timeout or abort
140
+ */
141
+ async waitForMessages({
142
+ threadId = null,
143
+ since = undefined,
144
+ timeoutMs = 60000,
145
+ signal = undefined,
146
+ limit = 1,
147
+ settleMs = 0,
148
+ } = {}) {
149
+ const { limit: max, settleMs: settle } = batchParams({ limit, settleMs });
150
+ const from = (cursor) => this.read(threadId || undefined, cursor, max);
151
+
82
152
  if (since !== undefined && since !== null && Number.isFinite(Number(since))) {
83
- const existing = this.read(threadId || undefined, Number(since), 1);
84
- if (existing.messages.length > 0) {
85
- return Promise.resolve(existing.messages[0]);
153
+ let r = from(Number(since));
154
+ if (r.messages.length > 0) {
155
+ if (settle > 0 && r.messages.length < max) {
156
+ r = await settleUntilFull(() => from(Number(since)), max, settle, signal);
157
+ }
158
+ return { messages: r.messages, hasMore: r.hasMore };
86
159
  }
87
160
  }
161
+ if (signal?.aborted) return { messages: [], hasMore: false };
162
+
163
+ const first = await this._waitNext({ threadId, timeoutMs, signal });
164
+ if (!first) return { messages: [], hasMore: false };
165
+ // From the first message on: it, and whatever arrived with it or while settling.
166
+ const r =
167
+ settle > 0 && max > 1
168
+ ? await settleUntilFull(() => from(first._cursor - 1), max, settle, signal)
169
+ : from(first._cursor - 1);
170
+ return r.messages.length ? { messages: r.messages, hasMore: r.hasMore } : { messages: [first], hasMore: false };
171
+ }
172
+
173
+ /**
174
+ * Resolve with the next message pushed (any thread, or `threadId`), or null on timeout or abort.
175
+ * @private
176
+ */
177
+ _waitNext({ threadId = null, timeoutMs = 60000, signal = undefined } = {}) {
88
178
  if (signal?.aborted) {
89
179
  return Promise.resolve(null);
90
180
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a "wait for messages" call returns, in one place: the HTTP route `GET /events/wait`, the MCP tool
3
+ * `zalo_wait_for_message` and `mcp wait` over the daemon channel all build it here, so they cannot drift.
4
+ *
5
+ * It used to hand back one message per call, so a bot behind a busy account made one round trip per
6
+ * message. `limit` (1-100, default 1) returns up to that many buffered messages at once, in cursor order,
7
+ * and `settleMs` waits that long after the first arrives so a burst lands in one reply.
8
+ *
9
+ * The single-message fields are unchanged and still filled (`message` is the first, `cursor` the last
10
+ * one returned, so `since=cursor` continues exactly where the batch ended); `messages`, `count` and
11
+ * `hasMore` are added. With `limit` 1 the reply is what it always was, plus those three fields.
12
+ */
13
+
14
+ /** Most messages one call returns. */
15
+ export const MAX_BATCH = 100;
16
+ /** Longest a call waits for company after the first message. */
17
+ export const MAX_SETTLE_MS = 5000;
18
+
19
+ /**
20
+ * Clamp the caller's batch settings; anything unusable is the default (one message, no settling).
21
+ *
22
+ * @param {{ limit?: unknown, settleMs?: unknown }} [raw]
23
+ * @returns {{ limit: number, settleMs: number }}
24
+ */
25
+ export function batchParams({ limit, settleMs } = {}) {
26
+ const l = Number(limit);
27
+ const s = Number(settleMs);
28
+ return {
29
+ limit: Number.isFinite(l) && l >= 1 ? Math.min(MAX_BATCH, Math.floor(l)) : 1,
30
+ settleMs: Number.isFinite(s) && s > 0 ? Math.min(MAX_SETTLE_MS, Math.floor(s)) : 0,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * @param {object} args
36
+ * @param {object[]} args.messages - Buffered messages, in cursor order (empty on timeout)
37
+ * @param {boolean} [args.hasMore] - More messages are already buffered after these
38
+ * @param {number} args.cursorWhenEmpty - The cursor to report on a timeout, so the caller can carry on from it
39
+ * @param {(m: object) => object} [args.decorate] - Adds what only this daemon knows, e.g. the thread name
40
+ * @returns {{ status: "message"|"timeout", message: object|null, messages: object[], count: number, hasMore: boolean, cursor: number }}
41
+ */
42
+ export function waitResult({ messages, hasMore = false, cursorWhenEmpty, decorate = (m) => m }) {
43
+ const out = messages.map(decorate);
44
+ return {
45
+ status: out.length ? "message" : "timeout",
46
+ message: out[0] ?? null,
47
+ messages: out,
48
+ count: out.length,
49
+ hasMore: Boolean(hasMore) && out.length > 0,
50
+ cursor: out.length ? messages[messages.length - 1]._cursor : cursorWhenEmpty,
51
+ };
52
+ }