talon-agent 3.25.3 → 3.26.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.25.3",
3
+ "version": "3.26.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -17,7 +17,13 @@ import {
17
17
  resolveKey,
18
18
  } from "../message-store.js";
19
19
  import { recordPin, listPins, forgetPin } from "../pins.js";
20
- import { resolveQuoted, sendContent, sendText, tryAction } from "./shared.js";
20
+ import {
21
+ resolveQuoted,
22
+ sendContent,
23
+ sendText,
24
+ tryAction,
25
+ boundedSend,
26
+ } from "./shared.js";
21
27
  import type { WhatsAppActionHandlers } from "./types.js";
22
28
 
23
29
  /** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
@@ -89,9 +95,11 @@ export const messagingHandlers: WhatsAppActionHandlers = {
89
95
  if ("error" in resolved) return { ok: false, error: resolved.error };
90
96
  // An empty emoji clears the reaction — WhatsApp's own semantics,
91
97
  // and the only way to take one back.
92
- await ctx.sock.sendMessage(ctx.chat!.jid, {
93
- react: { text: String(body.emoji ?? ""), key: resolved.key },
94
- });
98
+ await boundedSend("react", () =>
99
+ ctx.sock.sendMessage(ctx.chat!.jid, {
100
+ react: { text: String(body.emoji ?? ""), key: resolved.key },
101
+ }),
102
+ );
95
103
  return { ok: true };
96
104
  }),
97
105
 
@@ -103,10 +111,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
103
111
  if (!text.trim()) {
104
112
  return { ok: false, error: "edit_message: text is required" };
105
113
  }
106
- await ctx.sock.sendMessage(ctx.chat!.jid, {
107
- text: toWhatsAppText(text),
108
- edit: resolved.key,
109
- });
114
+ await boundedSend("edit", () =>
115
+ ctx.sock.sendMessage(ctx.chat!.jid, {
116
+ text: toWhatsAppText(text),
117
+ edit: resolved.key,
118
+ }),
119
+ );
110
120
  return { ok: true, message_id: resolved.stored.msgId };
111
121
  }),
112
122
 
@@ -114,7 +124,9 @@ export const messagingHandlers: WhatsAppActionHandlers = {
114
124
  tryAction("delete_message", async () => {
115
125
  const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
116
126
  if ("error" in resolved) return { ok: false, error: resolved.error };
117
- await ctx.sock.sendMessage(ctx.chat!.jid, { delete: resolved.key });
127
+ await boundedSend("delete", () =>
128
+ ctx.sock.sendMessage(ctx.chat!.jid, { delete: resolved.key }),
129
+ );
118
130
  forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
119
131
  return { ok: true };
120
132
  }),
@@ -166,11 +178,13 @@ export const messagingHandlers: WhatsAppActionHandlers = {
166
178
  const time =
167
179
  PIN_DURATIONS.find((d) => d >= requested) ??
168
180
  PIN_DURATIONS[PIN_DURATIONS.length - 1];
169
- await ctx.sock.sendMessage(ctx.chat!.jid, {
170
- pin: resolved.key,
171
- type: proto.PinInChat.Type.PIN_FOR_ALL,
172
- time,
173
- });
181
+ await boundedSend("pin", () =>
182
+ ctx.sock.sendMessage(ctx.chat!.jid, {
183
+ pin: resolved.key,
184
+ type: proto.PinInChat.Type.PIN_FOR_ALL,
185
+ time,
186
+ }),
187
+ );
174
188
  recordPin(ctx.chat!.chatId, resolved.stored);
175
189
  return { ok: true };
176
190
  }),
@@ -179,10 +193,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
179
193
  tryAction("unpin_message", async () => {
180
194
  const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
181
195
  if ("error" in resolved) return { ok: false, error: resolved.error };
182
- await ctx.sock.sendMessage(ctx.chat!.jid, {
183
- pin: resolved.key,
184
- type: proto.PinInChat.Type.UNPIN_FOR_ALL,
185
- });
196
+ await boundedSend("unpin", () =>
197
+ ctx.sock.sendMessage(ctx.chat!.jid, {
198
+ pin: resolved.key,
199
+ type: proto.PinInChat.Type.UNPIN_FOR_ALL,
200
+ }),
201
+ );
186
202
  forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
187
203
  return { ok: true };
188
204
  }),
@@ -163,6 +163,57 @@ function outboundTextMarker(content: AnyMessageContent): string {
163
163
  return "";
164
164
  }
165
165
 
166
+ // ── Serialized, time-bounded sends ─────────────────────────────────────────
167
+ //
168
+ // Ported from OpenClaw's socket-timing adapter. Two failure modes this
169
+ // removes, both observed live:
170
+ // - a send on a dying socket hanging a turn indefinitely ("timed out
171
+ // waiting for message" with nothing delivered and no error surfaced),
172
+ // - interleaved sends racing each other's Baileys internals.
173
+ // Every outbound WhatsApp operation goes through one FIFO per process
174
+ // with a hard timeout; on timeout the queue advances so later sends
175
+ // aren't wedged behind the dead one.
176
+
177
+ const SEND_TIMEOUT_MS = 60_000;
178
+ let sendTail: Promise<unknown> = Promise.resolve();
179
+
180
+ export class WhatsAppSendTimeoutError extends Error {
181
+ constructor(operation: string) {
182
+ super(
183
+ `WhatsApp ${operation} timed out after ${SEND_TIMEOUT_MS / 1000}s — ` +
184
+ `delivery state unknown (the socket may be dead or reconnecting)`,
185
+ );
186
+ this.name = "WhatsAppSendTimeoutError";
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Run one socket operation serialized behind every earlier one, bounded
192
+ * by SEND_TIMEOUT_MS. The timeout rejects THIS caller but releases the
193
+ * queue, so a wedged operation can't dam everything after it.
194
+ */
195
+ export function boundedSend<T>(
196
+ operation: string,
197
+ run: () => Promise<T>,
198
+ ): Promise<T> {
199
+ const prev = sendTail.catch(() => {});
200
+ const result = prev.then(() => {
201
+ let timer: ReturnType<typeof setTimeout> | undefined;
202
+ return Promise.race([
203
+ run(),
204
+ new Promise<never>((_, reject) => {
205
+ timer = setTimeout(
206
+ () => reject(new WhatsAppSendTimeoutError(operation)),
207
+ SEND_TIMEOUT_MS,
208
+ );
209
+ timer.unref?.();
210
+ }),
211
+ ]).finally(() => clearTimeout(timer));
212
+ });
213
+ sendTail = result.catch(() => {});
214
+ return result;
215
+ }
216
+
166
217
  /**
167
218
  * Send one content payload, remember the resulting message so later
168
219
  * tool calls can address it, and report its Talon numeric id.
@@ -173,7 +224,9 @@ export async function sendContent(
173
224
  content: AnyMessageContent,
174
225
  options: { quoted?: WAMessage } = {},
175
226
  ): Promise<ActionResult> {
176
- const sent = await ctx.sock.sendMessage(chat.jid, content, options);
227
+ const sent = await boundedSend("sendMessage", () =>
228
+ ctx.sock.sendMessage(chat.jid, content, options),
229
+ );
177
230
  ctx.gateway.incrementMessages(chat.numericChatId);
178
231
  if (!sent?.key) return { ok: true };
179
232
  const text =
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Atomic multi-file auth state for Baileys — a drop-in replacement for
3
+ * `useMultiFileAuthState` with two hardening changes ported from how
4
+ * OpenClaw persists WhatsApp credentials:
5
+ *
6
+ * 1. Every write is ATOMIC (tmp + rename via write-file-atomic). The
7
+ * upstream helper uses a bare `writeFile`, so a daemon restart or
8
+ * crash mid-write tears creds.json or a signal-key file. A torn
9
+ * signal key is invisible until the server starts rejecting stanzas
10
+ * ("smax-invalid: stanza rejected — likely stale device"), which is
11
+ * the documented prelude to WhatsApp unlinking the device — the
12
+ * exact sequence the live deployment logged before its
13
+ * `device_removed` conflict.
14
+ * 2. Writes to the same file are serialized through a promise chain,
15
+ * and `flushAuthWrites()` lets shutdown drain the queue before the
16
+ * process exits, so `talon stop` can't strand a half-persisted key.
17
+ *
18
+ * The on-disk format is byte-compatible with upstream: same folder, same
19
+ * file names, same BufferJSON encoding — existing auth dirs just work.
20
+ */
21
+
22
+ import { mkdir, readFile, stat, unlink } from "node:fs/promises";
23
+ import { join } from "node:path";
24
+ import writeFileAtomic from "write-file-atomic";
25
+ import { BufferJSON, initAuthCreds, proto } from "baileys";
26
+ import type { AuthenticationState, SignalDataTypeMap } from "baileys";
27
+
28
+ /** file path → tail of its write chain. Module-level: one dir per process. */
29
+ const writeTails = new Map<string, Promise<void>>();
30
+
31
+ function enqueueWrite(
32
+ filePath: string,
33
+ task: () => Promise<void>,
34
+ ): Promise<void> {
35
+ const tail = (writeTails.get(filePath) ?? Promise.resolve())
36
+ .catch(() => {})
37
+ .then(task);
38
+ writeTails.set(
39
+ filePath,
40
+ tail.catch(() => {}),
41
+ );
42
+ return tail;
43
+ }
44
+
45
+ /** Resolve when every queued auth write has settled. Call before exit. */
46
+ export async function flushAuthWrites(): Promise<void> {
47
+ await Promise.allSettled(writeTails.values());
48
+ }
49
+
50
+ /** Baileys' name mangling, kept identical for on-disk compatibility. */
51
+ function fixFileName(file: string): string {
52
+ return file.replace(/\//g, "__").replace(/:/g, "-");
53
+ }
54
+
55
+ export async function useAtomicAuthState(folder: string): Promise<{
56
+ state: AuthenticationState;
57
+ saveCreds: () => Promise<void>;
58
+ }> {
59
+ const info = await stat(folder).catch(() => undefined);
60
+ if (info && !info.isDirectory()) {
61
+ throw new Error(`not a directory: ${folder}`);
62
+ }
63
+ if (!info) await mkdir(folder, { recursive: true });
64
+
65
+ const writeData = (data: unknown, file: string): Promise<void> => {
66
+ const filePath = join(folder, fixFileName(file));
67
+ return enqueueWrite(filePath, () =>
68
+ writeFileAtomic(filePath, JSON.stringify(data, BufferJSON.replacer)),
69
+ );
70
+ };
71
+
72
+ const readData = async (file: string): Promise<unknown> => {
73
+ const filePath = join(folder, fixFileName(file));
74
+ // Reads wait for any pending write to that file, so a get() racing a
75
+ // set() sees the new value rather than the old file.
76
+ await (writeTails.get(filePath) ?? Promise.resolve()).catch(() => {});
77
+ try {
78
+ const raw = await readFile(filePath, "utf-8");
79
+ return JSON.parse(raw, BufferJSON.reviver);
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ const removeData = (file: string): Promise<void> => {
86
+ const filePath = join(folder, fixFileName(file));
87
+ return enqueueWrite(filePath, async () => {
88
+ await unlink(filePath).catch(() => {});
89
+ });
90
+ };
91
+
92
+ const creds =
93
+ ((await readData("creds.json")) as ReturnType<
94
+ typeof initAuthCreds
95
+ > | null) ?? initAuthCreds();
96
+
97
+ return {
98
+ state: {
99
+ creds,
100
+ keys: {
101
+ get: async <T extends keyof SignalDataTypeMap>(
102
+ type: T,
103
+ ids: string[],
104
+ ) => {
105
+ const data: { [id: string]: SignalDataTypeMap[T] } = {};
106
+ await Promise.all(
107
+ ids.map(async (id) => {
108
+ let value = await readData(`${type}-${id}.json`);
109
+ if (type === "app-state-sync-key" && value) {
110
+ value = proto.Message.AppStateSyncKeyData.fromObject(value);
111
+ }
112
+ data[id] = value as SignalDataTypeMap[T];
113
+ }),
114
+ );
115
+ return data;
116
+ },
117
+ set: async (data) => {
118
+ const tasks: Promise<void>[] = [];
119
+ for (const category in data) {
120
+ const entries = data[category as keyof SignalDataTypeMap];
121
+ for (const id in entries) {
122
+ const value = entries[id];
123
+ const file = `${category}-${id}.json`;
124
+ tasks.push(value ? writeData(value, file) : removeData(file));
125
+ }
126
+ }
127
+ await Promise.all(tasks);
128
+ },
129
+ },
130
+ },
131
+ saveCreds: () => writeData(creds, "creds.json"),
132
+ };
133
+ }
@@ -16,9 +16,7 @@
16
16
 
17
17
  import { rmSync } from "node:fs";
18
18
  import makeWASocket, {
19
- DisconnectReason,
20
19
  isJidGroup,
21
- useMultiFileAuthState,
22
20
  type WAMessage,
23
21
  type WASocket,
24
22
  } from "baileys";
@@ -53,7 +51,13 @@ import {
53
51
  seedMessageStore,
54
52
  } from "./message-store.js";
55
53
  import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
56
- import { nextPairingDelayMs, shouldNotifyPairingCode } from "./pairing.js";
54
+ import {
55
+ classifyClose,
56
+ nextPairingDelayMs,
57
+ REPLACED_BACKOFF_MS,
58
+ shouldNotifyPairingCode,
59
+ } from "./pairing.js";
60
+ import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
57
61
  import { notifyAdmin } from "../../core/notify.js";
58
62
  import {
59
63
  lookupWhatsAppChat,
@@ -433,7 +437,9 @@ export function createWhatsAppFrontend(
433
437
 
434
438
  /** One socket lifetime. Resolves with what the caller should do next. */
435
439
  async function connectOnce(): Promise<"reconnect" | "logged-out" | "stop"> {
436
- const { state, saveCreds } = await useMultiFileAuthState(dirs.whatsappAuth);
440
+ // Atomic replacement for Baileys' useMultiFileAuthState — same disk
441
+ // format, torn-write-proof (see auth-state.ts for why that matters).
442
+ const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
437
443
  const socket = makeWASocket({
438
444
  auth: state,
439
445
  logger: makeWaLogger(),
@@ -445,6 +451,11 @@ export function createWhatsAppFrontend(
445
451
  // a ~2½-minute socket lifetime in pairing mode, shorter than it
446
452
  // takes a human to pick up their phone and type the code.
447
453
  qrTimeout: 120_000,
454
+ // OpenClaw's production timings: the Baileys 20s connect timeout
455
+ // is tight on a loaded box, and a slightly faster keepalive spots
456
+ // a dead transport sooner.
457
+ connectTimeoutMs: 60_000,
458
+ keepAliveIntervalMs: 25_000,
448
459
  });
449
460
  sock = socket;
450
461
  socket.ev.on("creds.update", saveCreds);
@@ -545,12 +556,37 @@ export function createWhatsAppFrontend(
545
556
  { output?: { statusCode?: number } } | undefined
546
557
  )?.output?.statusCode;
547
558
  if (stopping) return resolve("stop");
548
- if (code === DisconnectReason.loggedOut) return resolve("logged-out");
549
- log(
550
- "whatsapp",
551
- `Connection closed (code ${code ?? "?"}) — reconnecting`,
552
- );
553
- return resolve("reconnect");
559
+ const disposition = classifyClose(code, state.creds.registered);
560
+ switch (disposition.kind) {
561
+ case "pairing-accepted":
562
+ // 515 right after a pairing code/QR is SUCCESS, not an
563
+ // error: WhatsApp requires one reconnect with the same
564
+ // credentials to complete the login.
565
+ log(
566
+ "whatsapp",
567
+ "Pairing accepted (515) — reconnecting to complete login",
568
+ );
569
+ reconnectDelay = RECONNECT_BASE_MS;
570
+ return resolve("reconnect");
571
+ case "replaced":
572
+ // Another socket owns this session (440). Fighting it with
573
+ // an instant reconnect just steals the session back and
574
+ // forth; sit out a full minute instead.
575
+ logWarn(
576
+ "whatsapp",
577
+ "Connection replaced by another client (440) — backing off",
578
+ );
579
+ reconnectDelay = REPLACED_BACKOFF_MS;
580
+ return resolve("reconnect");
581
+ case "logged-out":
582
+ return resolve("logged-out");
583
+ default:
584
+ log(
585
+ "whatsapp",
586
+ `Connection closed (code ${code ?? "?"}) — reconnecting`,
587
+ );
588
+ return resolve("reconnect");
589
+ }
554
590
  }
555
591
  });
556
592
  });
@@ -658,6 +694,10 @@ export function createWhatsAppFrontend(
658
694
  /* already closed */
659
695
  }
660
696
  sock = null;
697
+ // Drain queued credential writes before the process exits — a key
698
+ // half-written at shutdown is invisible until the server starts
699
+ // rejecting stanzas with it.
700
+ await flushAuthWrites();
661
701
  await gateway.stop();
662
702
  log("whatsapp", "WhatsApp frontend stopped");
663
703
  },
@@ -38,3 +38,38 @@ export function shouldNotifyPairingCode(
38
38
  if (lastNotifiedAt === undefined) return true;
39
39
  return now - lastNotifiedAt >= PAIRING_NOTIFY_MIN_GAP_MS;
40
40
  }
41
+
42
+ // ── Close-code taxonomy ─────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * What one Baileys `connection: "close"` means for the reconnect loop.
46
+ * Ported from how OpenClaw's connection controller reads DisconnectReason
47
+ * — the old handler knew only "loggedOut or not", which turned pairing
48
+ * success (515) into an anonymous reconnect and let another client's
49
+ * takeover (440) start a reconnect fight.
50
+ */
51
+ export type CloseDisposition =
52
+ | { kind: "pairing-accepted" } // 515: code entered — reconnect NOW, same creds
53
+ | { kind: "replaced" } // 440: another socket owns the session — back off hard
54
+ | { kind: "logged-out"; midPairing: boolean } // 401: dead creds
55
+ | { kind: "reconnect" }; // everything else: transient
56
+
57
+ export function classifyClose(
58
+ statusCode: number | undefined,
59
+ credsRegistered: boolean,
60
+ ): CloseDisposition {
61
+ switch (statusCode) {
62
+ case 515: // DisconnectReason.restartRequired — expected right after pairing
63
+ return { kind: "pairing-accepted" };
64
+ case 440: // DisconnectReason.connectionReplaced
65
+ return { kind: "replaced" };
66
+ case 401: // DisconnectReason.loggedOut
67
+ case 403: // DisconnectReason.forbidden — account-level, same recovery
68
+ return { kind: "logged-out", midPairing: !credsRegistered };
69
+ default:
70
+ return { kind: "reconnect" };
71
+ }
72
+ }
73
+
74
+ /** How long to sit out after a 440 — another client owns the session. */
75
+ export const REPLACED_BACKOFF_MS = 60_000;