talon-agent 5.18.2 → 5.19.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.
Files changed (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -17,6 +17,7 @@ import writeFileAtomic from "write-file-atomic";
17
17
  import { log, logError, logWarn } from "../../util/log.js";
18
18
  import { dirs, files } from "../../util/paths.js";
19
19
  import { formatSmartTimestamp } from "../../util/time.js";
20
+ import { createOutage, errorText } from "../health/outage.js";
20
21
 
21
22
  const SESSION_FILE = files.userSession;
22
23
 
@@ -152,6 +153,39 @@ export function withTimeout<T>(
152
153
  return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
153
154
  }
154
155
 
156
+ /** How long the monitor may fail to reconnect before the operator hears. */
157
+ const USERBOT_OUTAGE_MS = 15 * 60 * 1000;
158
+
159
+ const outage = createOutage({
160
+ key: "telegram.userbot",
161
+ thresholdMs: USERBOT_OUTAGE_MS,
162
+ severity: "warn",
163
+ describe: (err, mins) =>
164
+ `The Telegram user client has failed to reconnect for ${mins} min: ${err}. ` +
165
+ "Full history search and member lookups are unavailable.",
166
+ recovered: "The Telegram user client is reconnected.",
167
+ });
168
+
169
+ /** One failed monitor step, logged with its place in the outage. */
170
+ function noteFailure(step: string, err: unknown): void {
171
+ const { attempt, downMs } = outage.fail(err);
172
+ logWarn(
173
+ "userbot",
174
+ `userbot.reconnect.fail step=${step} attempt=${attempt} down_ms=${downMs} ` +
175
+ `next_check_ms=${CHECK_INTERVAL_MS} err=${errorText(err)}`,
176
+ );
177
+ }
178
+
179
+ /** A healthy monitor step; logs the end of an outage when there was one. */
180
+ function noteHealthy(via: string): void {
181
+ const ended = outage.ok();
182
+ if (!ended) return;
183
+ log(
184
+ "userbot",
185
+ `userbot.reconnect.ok via=${via} failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
186
+ );
187
+ }
188
+
155
189
  let reconnecting = false;
156
190
  // Bumped by stopConnectionMonitor so a tick whose awaits straddle a shutdown
157
191
  // can tell that its own client was torn down under it — the liveness probe
@@ -164,8 +198,19 @@ function startConnectionMonitor(): void {
164
198
  const gen = monitorGeneration;
165
199
  reconnectTimer = setInterval(async () => {
166
200
  if (gen !== monitorGeneration) return;
167
- if (!client) return;
168
201
  if (reconnecting) return; // prevent overlapping reconnect attempts
202
+ if (!client) {
203
+ // A failed re-init leaves no client. Retry it every tick; bailing
204
+ // here left the userbot dead until the daemon restarted.
205
+ if (!storedApiId || !storedApiHash) return;
206
+ reconnecting = true;
207
+ try {
208
+ await reinitClient();
209
+ } finally {
210
+ reconnecting = false;
211
+ }
212
+ return;
213
+ }
169
214
 
170
215
  // GramJS keeps `client.connected === true` even after the underlying
171
216
  // socket has silently died, so a real round-trip is the only reliable
@@ -179,6 +224,7 @@ function startConnectionMonitor(): void {
179
224
  RECONNECT_STEP_TIMEOUT_MS,
180
225
  "liveness probe",
181
226
  );
227
+ noteHealthy("probe");
182
228
  return; // genuinely alive
183
229
  } catch {
184
230
  if (gen !== monitorGeneration) return; // monitor stopped mid-probe
@@ -203,55 +249,73 @@ function startConnectionMonitor(): void {
203
249
  )
204
250
  ) {
205
251
  log("userbot", "Reconnected successfully.");
252
+ noteHealthy("reconnect");
206
253
  } else {
207
254
  logWarn("userbot", "Reconnected but not authorized.");
255
+ noteFailure("reconnect", "connected but not authorized");
208
256
  }
209
257
  } catch (err) {
210
258
  if (gen !== monitorGeneration) return; // stopped while reconnecting
211
259
  logError("userbot", "Reconnect failed", err);
212
- // Try a full re-init on next check
213
- if (storedApiId && storedApiHash) {
214
- try {
215
- // Drop the wedged client without awaiting it — its socket may be
216
- // the thing that's hung.
217
- const stale = client;
218
- client = null;
219
- void stale?.disconnect().catch(() => {});
220
- let sessionString = "";
221
- if (existsSync(SESSION_FILE)) {
222
- sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
223
- }
224
- const session = new StringSession(sessionString);
225
- client = new TelegramClient(session, storedApiId, storedApiHash, {
226
- connectionRetries: 5,
227
- });
228
- await withTimeout(
229
- client.connect(),
230
- RECONNECT_STEP_TIMEOUT_MS,
231
- "re-init connect",
232
- );
233
- if (
234
- await withTimeout(
235
- client.isUserAuthorized(),
236
- RECONNECT_STEP_TIMEOUT_MS,
237
- "re-init auth check",
238
- )
239
- ) {
240
- log("userbot", "Full re-init reconnect succeeded.");
241
- }
242
- } catch (retryErr) {
243
- logError("userbot", "Full re-init reconnect failed", retryErr);
244
- client = null;
245
- }
246
- }
260
+ if (storedApiId && storedApiHash) await reinitClient();
261
+ else noteFailure("reconnect", err);
247
262
  } finally {
248
263
  reconnecting = false;
249
264
  }
250
265
  }, CHECK_INTERVAL_MS);
251
266
  }
252
267
 
268
+ /**
269
+ * Replace the client with a fresh one from the saved session. On failure
270
+ * the slot is left empty (and the failed client closed); the monitor's
271
+ * next tick tries again.
272
+ */
273
+ async function reinitClient(): Promise<void> {
274
+ // Drop the wedged client without awaiting it — its socket may be the
275
+ // thing that's hung.
276
+ const stale = client;
277
+ client = null;
278
+ void stale?.disconnect().catch(() => {});
279
+ let fresh: TelegramClient | null = null;
280
+ try {
281
+ let sessionString = "";
282
+ if (existsSync(SESSION_FILE)) {
283
+ sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
284
+ }
285
+ const session = new StringSession(sessionString);
286
+ fresh = new TelegramClient(session, storedApiId, storedApiHash, {
287
+ connectionRetries: 5,
288
+ });
289
+ client = fresh;
290
+ await withTimeout(
291
+ fresh.connect(),
292
+ RECONNECT_STEP_TIMEOUT_MS,
293
+ "re-init connect",
294
+ );
295
+ if (
296
+ await withTimeout(
297
+ fresh.isUserAuthorized(),
298
+ RECONNECT_STEP_TIMEOUT_MS,
299
+ "re-init auth check",
300
+ )
301
+ ) {
302
+ log("userbot", "Full re-init reconnect succeeded.");
303
+ noteHealthy("re-init");
304
+ } else {
305
+ noteFailure("re-init", "connected but not authorized");
306
+ }
307
+ } catch (retryErr) {
308
+ logError("userbot", "Full re-init reconnect failed", retryErr);
309
+ noteFailure("re-init", retryErr);
310
+ if (client === fresh) client = null;
311
+ // Its connect may still be retrying in the background.
312
+ void fresh?.disconnect().catch(() => {});
313
+ }
314
+ }
315
+
253
316
  function stopConnectionMonitor(): void {
254
317
  monitorGeneration++;
318
+ outage.dispose();
255
319
  if (reconnectTimer) {
256
320
  clearInterval(reconnectTimer);
257
321
  reconnectTimer = null;
@@ -1,7 +1,7 @@
1
1
  /** `/reset`, `/resume`, `/rename` — session lifecycle. */
2
2
 
3
3
  import pc from "picocolors";
4
- import { formatTimeAgo } from "../renderer.js";
4
+ import { formatRelativeAge } from "../../../util/time.js";
5
5
  import { isTerminalChatId } from "../../../core/frontend-runtime/chat-id.js";
6
6
  import { resolveModel as coreResolveModel } from "../../../core/models/catalog.js";
7
7
  import {
@@ -49,7 +49,7 @@ export const resumeCommand: Command = {
49
49
  ? `"${s.info.sessionName}"`
50
50
  : pc.dim("(unnamed)");
51
51
  const turns = `${s.info.turns} turn${s.info.turns !== 1 ? "s" : ""}`;
52
- const ago = formatTimeAgo(s.info.lastActive);
52
+ const ago = formatRelativeAge(s.info.lastActive);
53
53
  const model = s.info.lastModel
54
54
  ? (coreResolveModel(s.info.lastModel)?.displayName ?? s.info.lastModel)
55
55
  : "";
@@ -8,9 +8,7 @@
8
8
  * - Renderer NEVER touches readline. Only this file does.
9
9
  *
10
10
  * `start()` returns once that prompt is drawn — the frontend lifecycle
11
- * contract (core/frontend-runtime/capabilities.ts). It used to park on a
12
- * promise that never resolved, which is why the composition root had to
13
- * special-case the terminal and start it without awaiting.
11
+ * contract (core/frontend-runtime/capabilities.ts).
14
12
  */
15
13
 
16
14
  import pc from "picocolors";
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import pc from "picocolors";
10
- import { formatUsd } from "../presentation/format.js";
10
+ import { formatTokenCount, formatUsd } from "../presentation/format.js";
11
11
 
12
12
  // ── Types ────────────────────────────────────────────────────────────────────
13
13
 
@@ -70,22 +70,6 @@ export function wrap(text: string, indent: number, maxWidth: number): string {
70
70
  .join("\n");
71
71
  }
72
72
 
73
- export function formatTimeAgo(ts: number): string {
74
- const sec = Math.floor((Date.now() - ts) / 1000);
75
- if (sec < 60) return "just now";
76
- const min = Math.floor(sec / 60);
77
- if (min < 60) return `${min}m ago`;
78
- const hr = Math.floor(min / 60);
79
- if (hr < 24) return `${hr}h ago`;
80
- return `${Math.floor(hr / 24)}d ago`;
81
- }
82
-
83
- function fmtTok(n: number): string {
84
- if (n < 1000) return String(n);
85
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
86
- return `${(n / 1_000_000).toFixed(1)}M`;
87
- }
88
-
89
73
  export function extractToolDetail(
90
74
  input: Record<string, unknown>,
91
75
  maxLen: number,
@@ -189,7 +173,7 @@ export function createRenderer(cols?: number, displayName = "Talon"): Renderer {
189
173
  if (info.sessionName) p.push(`"${info.sessionName}"`);
190
174
  p.push(
191
175
  `${info.turns} turn${info.turns !== 1 ? "s" : ""}`,
192
- `${fmtTok(info.inputTokens + info.outputTokens)} tok`,
176
+ `${formatTokenCount(info.inputTokens + info.outputTokens)} tok`,
193
177
  );
194
178
  if (typeof info.cacheHitPct === "number") {
195
179
  p.push(`${info.cacheHitPct}% cache`);
@@ -33,6 +33,16 @@ import { mediaHandlers } from "./media.js";
33
33
  import { messagingHandlers } from "./messaging.js";
34
34
  import { moderationHandlers } from "./moderation.js";
35
35
  import type { WhatsAppActionContext, WhatsAppActionHandlers } from "./types.js";
36
+ import {
37
+ createDeliveryTracker,
38
+ trackDeliveries,
39
+ } from "../../health/delivery.js";
40
+
41
+ const delivery = createDeliveryTracker("whatsapp", "WhatsApp", "whatsapp");
42
+
43
+ /** A cross-send names its destination; everything else goes to `chatId`. */
44
+ const destination = (body: Record<string, unknown>, chatId: number) =>
45
+ body.target !== undefined ? String(body.target) : chatId;
36
46
 
37
47
  // Null-prototype so a request `action` of "toString" / "constructor" can't
38
48
  // resolve an inherited Object.prototype method via `handlers[action]`.
@@ -62,7 +72,7 @@ export function createWhatsAppActionHandler(
62
72
  ) {
63
73
  const scheduledMessages = new Map<string, ReturnType<typeof setTimeout>>();
64
74
 
65
- return async (
75
+ const dispatch = async (
66
76
  body: Record<string, unknown>,
67
77
  chatId: number,
68
78
  ): Promise<ActionResult | null> => {
@@ -118,4 +128,5 @@ export function createWhatsAppActionHandler(
118
128
  ? { ...result, chat_id: chat.chatId }
119
129
  : result;
120
130
  };
131
+ return trackDeliveries(delivery, dispatch, destination);
121
132
  }
@@ -17,6 +17,9 @@ import { tryAction } from "./try-action.js";
17
17
  import { resolveQuoted, sendContent, sendText, boundedSend } from "./send.js";
18
18
  import type { WhatsAppActionHandlers } from "./types.js";
19
19
 
20
+ /** The tool schema's documented ceiling (1-86400), as telegram/discord clamp. */
21
+ const MAX_DELAY_SEC = 24 * 60 * 60;
22
+
20
23
  /** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
21
24
  const PIN_DURATIONS = [86_400, 604_800, 2_592_000] as const;
22
25
 
@@ -229,16 +232,18 @@ export const messagingHandlers: WhatsAppActionHandlers = {
229
232
  schedule_message: (body, _chatId, ctx) =>
230
233
  tryAction("schedule_message", async () => {
231
234
  const text = String(body.text ?? "");
232
- const seconds = Number(body.delay_seconds ?? 0);
235
+ const requested = Number(body.delay_seconds ?? 0);
233
236
  if (!text.trim()) {
234
237
  return { ok: false, error: "schedule_message: text is required" };
235
238
  }
236
- if (!Number.isFinite(seconds) || seconds <= 0) {
239
+ if (!Number.isFinite(requested) || requested <= 0) {
237
240
  return {
238
241
  ok: false,
239
242
  error: "schedule_message: delay_seconds must be a positive number",
240
243
  };
241
244
  }
245
+ // Past setTimeout's 2^31-1 ms (~24.8 days) the timer fires at once.
246
+ const seconds = Math.min(MAX_DELAY_SEC, requested);
242
247
  const id = randomUUID();
243
248
  const chat = ctx.chat!;
244
249
  const timer = setTimeout(() => {
@@ -11,7 +11,10 @@ import makeWASocket, { type AuthenticationState, type WASocket } from "baileys";
11
11
  import qrcode from "qrcode-terminal";
12
12
  import { log, logError, logWarn } from "../../../util/log.js";
13
13
  import { dirs } from "../../../util/paths.js";
14
- import { notifyAdmin } from "../../../core/frontend-runtime/admin-notify.js";
14
+ import {
15
+ raiseAlert,
16
+ resolveAlert,
17
+ } from "../../../core/frontend-runtime/alerts.js";
15
18
  import { useAtomicAuthState } from "./auth-state.js";
16
19
  import { bareId } from "./identity.js";
17
20
  import { handleInbound } from "../messages/inbound.js";
@@ -23,6 +26,7 @@ import {
23
26
  type WhatsAppRuntime,
24
27
  } from "../runtime.js";
25
28
  import { makeWaLogger } from "./wa-logger.js";
29
+ import { errorText } from "../../health/outage.js";
26
30
 
27
31
  /** What the connection loop should do after a socket ends. */
28
32
  type ConnectOutcome = "reconnect" | "logged-out" | "unpaired" | "stop";
@@ -64,9 +68,11 @@ function onQr(runtime: WhatsAppRuntime, qr: string): void {
64
68
  qrcode.generate(qr, { small: true });
65
69
  if (!runtime.unpairedNotified) {
66
70
  runtime.unpairedNotified = true;
67
- void notifyAdmin(
68
- "📱 WhatsApp is not linked. Send /whatsapp pair when you're " +
71
+ raiseAlert(
72
+ "whatsapp.linked",
73
+ "WhatsApp is not linked. Send /whatsapp pair when you're " +
69
74
  "ready and I'll reply with a QR to scan.",
75
+ { severity: "warn" },
70
76
  );
71
77
  }
72
78
  }
@@ -77,10 +83,10 @@ function onOpen(runtime: WhatsAppRuntime, socket: WASocket): void {
77
83
  .filter((id): id is string => Boolean(id))
78
84
  .map(bareId);
79
85
  runtime.reconnectDelay = RECONNECT_BASE_MS;
80
- if (runtime.unpairedNotified) {
81
- void notifyAdmin("✅ WhatsApp linked and connected.");
82
- }
86
+ // Announces only when a "not linked" / "unlinked" alert went out.
87
+ resolveAlert("whatsapp.linked", "WhatsApp linked and connected.");
83
88
  runtime.unpairedNotified = false;
89
+ runtime.health.opened();
84
90
  log(
85
91
  "whatsapp",
86
92
  `Connected as ${socket.user?.name ?? "?"} (${runtime.selfIds.join("/") || "?"})`,
@@ -91,6 +97,7 @@ function onClose(
91
97
  runtime: WhatsAppRuntime,
92
98
  state: AuthenticationState,
93
99
  code: number | undefined,
100
+ reason: string,
94
101
  ): ConnectOutcome {
95
102
  if (runtime.stopping) return "stop";
96
103
  const disposition = classifyClose(code, state.creds.registered);
@@ -113,6 +120,7 @@ function onClose(
113
120
  "whatsapp",
114
121
  "Connection replaced by another client (440) — backing off",
115
122
  );
123
+ runtime.health.noteClose("replaced by another client (440)");
116
124
  runtime.reconnectDelay = REPLACED_BACKOFF_MS;
117
125
  return "reconnect";
118
126
  case "logged-out":
@@ -128,6 +136,7 @@ function onClose(
128
136
  // session on its first ordinary disconnect.
129
137
  if (!isPaired(state.creds)) return "unpaired";
130
138
  log("whatsapp", `Connection closed (code ${code ?? "?"}) — reconnecting`);
139
+ runtime.health.noteClose(`code=${code ?? "?"} ${reason}`);
131
140
  return "reconnect";
132
141
  }
133
142
  }
@@ -163,6 +172,9 @@ async function connectOnce(
163
172
  // Atomic replacement for Baileys' useMultiFileAuthState — same disk
164
173
  // format, torn-write-proof (see auth-state.ts for why that matters).
165
174
  const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
175
+ // stop() ends runtime.sock, which is still null while auth loads; a
176
+ // socket built after it would never close and stop() would wait forever.
177
+ if (runtime.stopping) return "stop";
166
178
  const socket = makeWASocket({
167
179
  auth: state,
168
180
  logger: makeWaLogger(),
@@ -195,7 +207,8 @@ async function connectOnce(
195
207
  lastDisconnect?.error as
196
208
  { output?: { statusCode?: number } } | undefined
197
209
  )?.output?.statusCode;
198
- resolve(onClose(runtime, state, code));
210
+ const reason = errorText(lastDisconnect?.error ?? "closed");
211
+ resolve(onClose(runtime, state, code, reason));
199
212
  }
200
213
  });
201
214
  });
@@ -260,6 +273,7 @@ export async function runConnectionLoop(
260
273
  try {
261
274
  await connectionLoop(runtime, signalReady);
262
275
  } finally {
276
+ runtime.health.dispose();
263
277
  signalReady();
264
278
  log("whatsapp", "WhatsApp connection loop ended");
265
279
  }
@@ -290,6 +304,7 @@ async function connectionLoop(
290
304
  // half-written auth dir, say) must not hold the boot open while
291
305
  // the loop keeps trying.
292
306
  signalReady();
307
+ runtime.health.noteClose(`socket error: ${errorText(err)}`);
293
308
  outcome = "reconnect";
294
309
  }
295
310
  runtime.sock = null;
@@ -306,10 +321,13 @@ async function connectionLoop(
306
321
  "whatsapp",
307
322
  "Logged out by WhatsApp — parked until /whatsapp pair re-links",
308
323
  );
309
- void notifyAdmin(
310
- "⚠️ WhatsApp unlinked this device. When you're ready to " +
324
+ runtime.health.dispose();
325
+ raiseAlert(
326
+ "whatsapp.linked",
327
+ "WhatsApp unlinked this device. When you're ready to " +
311
328
  "re-link, send /whatsapp pair and scan the QR I reply with. " +
312
329
  "Nothing is retried until then.",
330
+ { severity: "warn" },
313
331
  );
314
332
  rmSync(dirs.whatsappAuth, { recursive: true, force: true });
315
333
  await parkUntilPaired(runtime);
@@ -318,10 +336,12 @@ async function connectionLoop(
318
336
  }
319
337
  if (outcome === "unpaired") {
320
338
  log("whatsapp", "Not paired — parked until /whatsapp pair links");
339
+ runtime.health.dispose();
321
340
  await parkUntilPaired(runtime);
322
341
  runtime.reconnectDelay = RECONNECT_BASE_MS;
323
342
  continue;
324
343
  }
344
+ runtime.health.reconnecting(runtime.reconnectDelay);
325
345
  await sleep(runtime, runtime.reconnectDelay);
326
346
  runtime.reconnectDelay = Math.min(
327
347
  runtime.reconnectDelay * 2,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Connection health — the operator hears when the WhatsApp socket keeps
3
+ * dropping instead of staying up.
4
+ *
5
+ * Every unexpected close is logged with its attempt number, the time the
6
+ * connection has been unstable, the backoff before the next socket, and
7
+ * the reason. Closes that keep coming for `CONNECTION_OUTAGE_MS` raise
8
+ * `whatsapp.connection`. An open alone does not end the outage — a socket
9
+ * that opens and dies again is exactly the flapping this watches for — so
10
+ * the outage ends only once a socket has stayed open for `STABLE_OPEN_MS`.
11
+ *
12
+ * Unlinking is not a connection fault: the loop parks and the
13
+ * `whatsapp.linked` alert speaks for it (connection.ts).
14
+ */
15
+
16
+ import { log, logWarn } from "../../../util/log.js";
17
+ import { createOutage } from "../../health/outage.js";
18
+
19
+ const CONNECTION_OUTAGE_MS = 10 * 60_000;
20
+ /** How long a socket must stay open before the connection counts as stable. */
21
+ const STABLE_OPEN_MS = 60_000;
22
+
23
+ export type ConnectionHealth = {
24
+ /** Record why the socket just closed; `reconnecting` counts it. */
25
+ noteClose(detail: string): void;
26
+ /** The loop will build a new socket after `backoffMs`. */
27
+ reconnecting(backoffMs: number): void;
28
+ opened(): void;
29
+ /** Forget the outage without resolving it — park or shutdown. */
30
+ dispose(): void;
31
+ };
32
+
33
+ export function createConnectionHealth(
34
+ thresholdMs = CONNECTION_OUTAGE_MS,
35
+ stableMs = STABLE_OPEN_MS,
36
+ ): ConnectionHealth {
37
+ const outage = createOutage({
38
+ key: "whatsapp.connection",
39
+ thresholdMs,
40
+ describe: (err, mins) =>
41
+ `The WhatsApp connection has kept dropping for ${mins} min: ${err}. ` +
42
+ "Messages may not be received or delivered.",
43
+ recovered: "The WhatsApp connection is stable again.",
44
+ });
45
+ let pending: string | null = null;
46
+ let stableTimer: ReturnType<typeof setTimeout> | null = null;
47
+ const clearStable = (): void => {
48
+ if (stableTimer) clearTimeout(stableTimer);
49
+ stableTimer = null;
50
+ };
51
+
52
+ return {
53
+ noteClose(detail) {
54
+ clearStable();
55
+ pending = detail;
56
+ },
57
+ reconnecting(backoffMs) {
58
+ // A close nobody noted (515 finishing a pairing) is not a fault.
59
+ if (pending === null) return;
60
+ const detail = pending;
61
+ pending = null;
62
+ const { attempt, downMs } = outage.fail(detail);
63
+ logWarn(
64
+ "whatsapp",
65
+ `connection.reconnect attempt=${attempt} down_ms=${downMs} backoff_ms=${backoffMs} err=${detail}`,
66
+ );
67
+ },
68
+ opened() {
69
+ clearStable();
70
+ if (!outage.down) return;
71
+ stableTimer = setTimeout(() => {
72
+ stableTimer = null;
73
+ const ended = outage.ok();
74
+ if (ended) {
75
+ log(
76
+ "whatsapp",
77
+ `connection.stable failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
78
+ );
79
+ }
80
+ }, stableMs);
81
+ stableTimer.unref?.();
82
+ },
83
+ dispose() {
84
+ clearStable();
85
+ pending = null;
86
+ outage.dispose();
87
+ },
88
+ };
89
+ }
@@ -2,8 +2,8 @@
2
2
  * Identity resolution — the LID/phone-number duality.
3
3
  *
4
4
  * WhatsApp addresses people two ways. The historical form is the phone
5
- * number (`353834733284@s.whatsapp.net`, "PN"); the newer privacy form
6
- * is a linked identity (`180753715482747@lid`, "LID") that hides the
5
+ * number (`447700900102@s.whatsapp.net`, "PN"); the newer privacy form
6
+ * is a linked identity (`190000000000001@lid`, "LID") that hides the
7
7
  * number. Which one arrives depends on the sender's privacy settings and
8
8
  * the chat's addressing mode, and neither is derivable from the other —
9
9
  * they are looked up.
@@ -22,9 +22,9 @@
22
22
  import { isLidUser, jidNormalizedUser, type WASocket } from "baileys";
23
23
 
24
24
  export type Identity = {
25
- /** Phone-number form, digits only, when known: "353834733284". */
25
+ /** Phone-number form, digits only, when known: "447700900102". */
26
26
  phone?: string;
27
- /** LID form, digits only, when known: "180753715482747". */
27
+ /** LID form, digits only, when known: "190000000000001". */
28
28
  lid?: string;
29
29
  /** Every bare id this person is known by — what allowlists match on. */
30
30
  ids: string[];
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * WhatsApp frontend runtime — the state every module shares.
3
3
  *
4
- * `createWhatsAppFrontend` used to hold all of this as closure variables
5
- * with every handler nested inside it. It is now one explicit object,
6
- * constructed once, that each module (access, inbound, connection) takes
7
- * as its first parameter. The runtime carries the parsed settings, the
8
- * allow-lists and the live socket state; the modules own the behaviour.
4
+ * One explicit object, constructed once, that each module (access,
5
+ * inbound, connection) takes as its first parameter. The runtime carries
6
+ * the parsed settings, the allow-lists and the live socket state; the
7
+ * modules own the behaviour.
9
8
  */
10
9
 
11
10
  import type { WASocket } from "baileys";
12
11
  import type { TalonConfig } from "../../core/config/index.js";
13
12
  import type { Gateway } from "../../core/engine/gateway.js";
14
13
  import { bareId } from "./connection/identity.js";
14
+ import {
15
+ createConnectionHealth,
16
+ type ConnectionHealth,
17
+ } from "./connection/health.js";
15
18
 
16
19
  type WhatsAppSettings = {
17
20
  allowedJids: string[];
@@ -47,6 +50,8 @@ export type WhatsAppRuntime = {
47
50
  reconnectDelay: number;
48
51
  /** One "not linked" admin note per outage, not one per QR window. */
49
52
  unpairedNotified: boolean;
53
+ /** Reconnect logging and the `whatsapp.connection` alert. */
54
+ readonly health: ConnectionHealth;
50
55
  /** Our own ids (phone and LID), once connected — for mention detection. */
51
56
  selfIds: string[];
52
57
  };
@@ -76,6 +81,7 @@ export function createWhatsAppRuntime(
76
81
  stopRequest: new AbortController(),
77
82
  reconnectDelay: RECONNECT_BASE_MS,
78
83
  unpairedNotified: false,
84
+ health: createConnectionHealth(),
79
85
  selfIds: [],
80
86
  };
81
87
  }
@@ -34,6 +34,7 @@
34
34
  import { createReadStream } from "node:fs";
35
35
  import { createRequire } from "node:module";
36
36
  import { fileURLToPath } from "node:url";
37
+ import { logDebug, logWarn } from "../util/log.js";
37
38
  import { BLAKE3_WASM_BASE64 } from "./blake3-wasm-bytes.js";
38
39
  import {
39
40
  allocRegion,
@@ -99,13 +100,38 @@ function loadNativeBlake3(): NativeBlake3 | null {
99
100
  const addon = requireAddon(candidate) as NativeBlake3;
100
101
  // Trust nothing that can't produce a known digest — a truncated or
101
102
  // wrong-arch artifact fails here and wasm silently takes over.
102
- if (addon.hashHex(Buffer.alloc(0)) !== EMPTY_DIGEST) return null;
103
+ if (addon.hashHex(Buffer.alloc(0)) !== EMPTY_DIGEST) {
104
+ reportAddonRejected(candidate, "digest mismatch");
105
+ return null;
106
+ }
103
107
  return addon;
104
- } catch {
108
+ } catch (err) {
109
+ reportAddonRejected(candidate, err);
105
110
  return null;
106
111
  }
107
112
  }
108
113
 
114
+ /**
115
+ * Wasm takes over either way; the log says why. An absent default
116
+ * artifact is the normal npm install; an explicit TALON_BLAKE3_NODE
117
+ * that fails to load is operator config silently not taking effect.
118
+ */
119
+ function reportAddonRejected(candidate: string, reason: unknown): void {
120
+ const code = (reason as { code?: unknown } | null)?.code;
121
+ const why = reason instanceof Error ? reason.message : String(reason);
122
+ if (process.env.TALON_BLAKE3_NODE) {
123
+ logWarn(
124
+ "native",
125
+ `TALON_BLAKE3_NODE addon rejected, using wasm path=${candidate}: ${why}`,
126
+ );
127
+ } else if (code !== "MODULE_NOT_FOUND") {
128
+ logDebug(
129
+ "native",
130
+ `blake3 addon rejected, using wasm path=${candidate}: ${why}`,
131
+ );
132
+ }
133
+ }
134
+
109
135
  /** Tests swap addons via TALON_BLAKE3_NODE and need the memo dropped. */
110
136
  export function _resetNativeBlake3ForTesting(): void {
111
137
  nativeAddon = undefined;