talon-agent 1.0.0 → 1.2.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -57,19 +57,35 @@ export async function handleSharedAction(
57
57
 
58
58
  case "search_history": {
59
59
  const limit = Math.min(100, Number(body.limit ?? 20));
60
- return { ok: true, text: searchHistory(String(chatId), String(body.query ?? ""), limit) };
60
+ return {
61
+ ok: true,
62
+ text: searchHistory(String(chatId), String(body.query ?? ""), limit),
63
+ };
61
64
  }
62
65
 
63
66
  case "get_user_messages": {
64
67
  const limit = Math.min(50, Number(body.limit ?? 20));
65
- return { ok: true, text: getMessagesByUser(String(chatId), String(body.user_name ?? ""), limit) };
68
+ return {
69
+ ok: true,
70
+ text: getMessagesByUser(
71
+ String(chatId),
72
+ String(body.user_name ?? ""),
73
+ limit,
74
+ ),
75
+ };
66
76
  }
67
77
 
68
78
  case "list_known_users":
69
79
  return { ok: true, text: getKnownUsers(String(chatId)) };
70
80
 
71
81
  case "list_media":
72
- return { ok: true, text: formatMediaIndex(String(chatId), Math.min(20, Number(body.limit ?? 10))) };
82
+ return {
83
+ ok: true,
84
+ text: formatMediaIndex(
85
+ String(chatId),
86
+ Math.min(20, Number(body.limit ?? 10)),
87
+ ),
88
+ };
73
89
 
74
90
  // ── Web search (SearXNG) ────────────────────────────────────────────
75
91
 
@@ -91,14 +107,34 @@ export async function handleSharedAction(
91
107
  try {
92
108
  const resp = await fetch(
93
109
  `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
94
- { signal: AbortSignal.timeout(8_000), headers: { "X-Subscription-Token": braveKey, Accept: "application/json" } },
110
+ {
111
+ signal: AbortSignal.timeout(8_000),
112
+ headers: {
113
+ "X-Subscription-Token": braveKey,
114
+ Accept: "application/json",
115
+ },
116
+ },
95
117
  );
96
118
  if (resp.ok) {
97
- const data = await resp.json() as { web?: { results?: Array<{ title: string; url: string; description: string }> } };
98
- results = (data.web?.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.description ?? "" }));
119
+ const data = (await resp.json()) as {
120
+ web?: {
121
+ results?: Array<{
122
+ title: string;
123
+ url: string;
124
+ description: string;
125
+ }>;
126
+ };
127
+ };
128
+ results = (data.web?.results ?? []).map((r) => ({
129
+ title: r.title,
130
+ url: r.url,
131
+ snippet: r.description ?? "",
132
+ }));
99
133
  provider = "Brave";
100
134
  }
101
- } catch { /* fall through to SearXNG */ }
135
+ } catch {
136
+ /* fall through to SearXNG */
137
+ }
102
138
  }
103
139
 
104
140
  // SearXNG fallback
@@ -109,18 +145,33 @@ export async function handleSharedAction(
109
145
  { signal: AbortSignal.timeout(10_000) },
110
146
  );
111
147
  if (resp.ok) {
112
- const data = await resp.json() as { results?: Array<{ title: string; url: string; content: string }> };
113
- results = (data.results ?? []).slice(0, limit).map((r) => ({ title: r.title, url: r.url, snippet: r.content ?? "" }));
148
+ const data = (await resp.json()) as {
149
+ results?: Array<{ title: string; url: string; content: string }>;
150
+ };
151
+ results = (data.results ?? []).slice(0, limit).map((r) => ({
152
+ title: r.title,
153
+ url: r.url,
154
+ snippet: r.content ?? "",
155
+ }));
114
156
  provider = "SearXNG";
115
157
  }
116
- } catch { /* both failed */ }
158
+ } catch {
159
+ /* both failed */
160
+ }
117
161
  }
118
162
 
119
- if (results.length === 0) return { ok: true, text: `No results for "${query}".` };
120
- const formatted = results.map((r, i) =>
121
- `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 200)}`
122
- ).join("\n\n");
123
- return { ok: true, text: `Search results for "${query}" (via ${provider}):\n\n${formatted}` };
163
+ if (results.length === 0)
164
+ return { ok: true, text: `No results for "${query}".` };
165
+ const formatted = results
166
+ .map(
167
+ (r, i) =>
168
+ `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 200)}`,
169
+ )
170
+ .join("\n\n");
171
+ return {
172
+ ok: true,
173
+ text: `Search results for "${query}" (via ${provider}):\n\n${formatted}`,
174
+ };
124
175
  }
125
176
 
126
177
  // ── Web fetch ────────────────────────────────────────────────────────
@@ -145,46 +196,92 @@ export async function handleSharedAction(
145
196
  if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
146
197
  const ct = resp.headers.get("content-type") ?? "";
147
198
 
199
+ // Reject oversized responses before downloading the body.
200
+ // The Content-Length header is advisory but saves bandwidth when present.
201
+ const MAX_BYTES = 20 * 1024 * 1024; // 20 MB
202
+ const contentLength = resp.headers.get("content-length");
203
+ if (contentLength && Number(contentLength) > MAX_BYTES) {
204
+ return {
205
+ ok: false,
206
+ error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max 20MB)`,
207
+ };
208
+ }
209
+
148
210
  // Binary content: download and save to workspace
149
211
  const mimeType = ct.split(";")[0].trim().toLowerCase();
150
- const isText = mimeType.startsWith("text/") || mimeType === "application/json";
212
+ const isText =
213
+ mimeType.startsWith("text/") || mimeType === "application/json";
151
214
  if (!isText) {
152
215
  const buffer = Buffer.from(await resp.arrayBuffer());
153
- if (buffer.length > 20 * 1024 * 1024) return { ok: false, error: "File too large (max 20MB)" };
154
- if (buffer.length === 0) return { ok: false, error: "Empty response (0 bytes)" };
216
+ if (buffer.length > MAX_BYTES)
217
+ return { ok: false, error: "File too large (max 20MB)" };
218
+ if (buffer.length === 0)
219
+ return { ok: false, error: "Empty response (0 bytes)" };
155
220
 
156
221
  // Validate magic bytes — prevent saving HTML error pages as images
157
222
  // (servers can return error pages with image content-type headers)
158
223
  const magic = buffer.subarray(0, 16);
159
224
  const isRealImage =
160
- (magic[0] === 0xFF && magic[1] === 0xD8) || // JPEG
161
- (magic[0] === 0x89 && magic[1] === 0x50 && magic[2] === 0x4E && magic[3] === 0x47) || // PNG
225
+ (magic[0] === 0xff && magic[1] === 0xd8) || // JPEG
226
+ (magic[0] === 0x89 &&
227
+ magic[1] === 0x50 &&
228
+ magic[2] === 0x4e &&
229
+ magic[3] === 0x47) || // PNG
162
230
  (magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46) || // GIF
163
- (magic[0] === 0x52 && magic[1] === 0x49 && magic[2] === 0x46 && magic[3] === 0x46 &&
164
- magic[8] === 0x57 && magic[9] === 0x45 && magic[10] === 0x42 && magic[11] === 0x50); // WebP
231
+ (magic[0] === 0x52 &&
232
+ magic[1] === 0x49 &&
233
+ magic[2] === 0x46 &&
234
+ magic[3] === 0x46 &&
235
+ magic[8] === 0x57 &&
236
+ magic[9] === 0x45 &&
237
+ magic[10] === 0x42 &&
238
+ magic[11] === 0x50); // WebP
165
239
 
166
240
  // If content-type says image but bytes say otherwise, treat as text
167
241
  if (ct.startsWith("image/") && !isRealImage) {
168
242
  const text = extractText(buffer.toString("utf-8"), 500);
169
- return { ok: false, error: `Server returned an error page instead of an image. Content: ${text}` };
243
+ return {
244
+ ok: false,
245
+ error: `Server returned an error page instead of an image. Content: ${text}`,
246
+ };
170
247
  }
171
248
 
172
249
  const ext = isRealImage
173
- ? ((magic[0] === 0xFF) ? "jpg" : (magic[0] === 0x89) ? "png" : (magic[0] === 0x47) ? "gif" : "webp")
174
- : ct.includes("pdf") ? "pdf" : ct.includes("zip") ? "zip" : "bin";
250
+ ? magic[0] === 0xff
251
+ ? "jpg"
252
+ : magic[0] === 0x89
253
+ ? "png"
254
+ : magic[0] === 0x47
255
+ ? "gif"
256
+ : "webp"
257
+ : ct.includes("pdf")
258
+ ? "pdf"
259
+ : ct.includes("zip")
260
+ ? "zip"
261
+ : "bin";
175
262
  const uploadsDir = dirs.uploads;
176
- if (!existsSync(uploadsDir)) mkdirSync(uploadsDir, { recursive: true });
263
+ if (!existsSync(uploadsDir))
264
+ mkdirSync(uploadsDir, { recursive: true });
177
265
  const filePath = resolve(uploadsDir, `${Date.now()}-fetched.${ext}`);
178
266
  writeFileSync(filePath, buffer);
179
- const typeLabel = isRealImage ? "image" : ct.split("/")[1]?.split(";")[0] ?? "file";
180
- return { ok: true, text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").` };
267
+ const typeLabel = isRealImage
268
+ ? "image"
269
+ : (ct.split("/")[1]?.split(";")[0] ?? "file");
270
+ return {
271
+ ok: true,
272
+ text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").`,
273
+ };
181
274
  }
182
275
  const raw = await resp.text();
183
276
  const text = extractText(raw);
184
- if (text.length < 20) return { ok: true, text: "(Page has no readable content)" };
277
+ if (text.length < 20)
278
+ return { ok: true, text: "(Page has no readable content)" };
185
279
  return { ok: true, text };
186
280
  } catch (err) {
187
- return { ok: false, error: `Fetch failed: ${err instanceof Error ? err.message : err}` };
281
+ return {
282
+ ok: false,
283
+ error: `Fetch failed: ${err instanceof Error ? err.message : err}`,
284
+ };
188
285
  }
189
286
  }
190
287
 
@@ -199,33 +296,62 @@ export async function handleSharedAction(
199
296
 
200
297
  if (!schedule) return { ok: false, error: "Missing schedule expression" };
201
298
  if (!content) return { ok: false, error: "Missing content" };
202
- if (content.length > 10_000) return { ok: false, error: "Content too long (max 10,000 chars)" };
299
+ if (content.length > 10_000)
300
+ return { ok: false, error: "Content too long (max 10,000 chars)" };
203
301
 
204
302
  const validation = validateCronExpression(schedule, timezone);
205
- if (!validation.valid) return { ok: false, error: `Invalid cron expression: ${validation.error}` };
303
+ if (!validation.valid)
304
+ return {
305
+ ok: false,
306
+ error: `Invalid cron expression: ${validation.error}`,
307
+ };
206
308
 
207
309
  const id = generateCronId();
208
- addCronJob({ id, chatId: String(chatId), schedule, type: jobType, content, name, enabled: true, createdAt: Date.now(), runCount: 0, timezone });
310
+ addCronJob({
311
+ id,
312
+ chatId: String(chatId),
313
+ schedule,
314
+ type: jobType,
315
+ content,
316
+ name,
317
+ enabled: true,
318
+ createdAt: Date.now(),
319
+ runCount: 0,
320
+ timezone,
321
+ });
209
322
  log("gateway", `create_cron_job: "${name}" [${schedule}]`);
210
- return { ok: true, text: `Created cron job "${name}" (id: ${id})\nSchedule: ${schedule}\nType: ${jobType}\nNext run: ${validation.next ?? "unknown"}` };
323
+ return {
324
+ ok: true,
325
+ text: `Created cron job "${name}" (id: ${id})\nSchedule: ${schedule}\nType: ${jobType}\nNext run: ${validation.next ?? "unknown"}`,
326
+ };
211
327
  }
212
328
 
213
329
  case "list_cron_jobs": {
214
330
  const jobs = getCronJobsForChat(String(chatId));
215
- if (jobs.length === 0) return { ok: true, text: "No cron jobs in this chat." };
331
+ if (jobs.length === 0)
332
+ return { ok: true, text: "No cron jobs in this chat." };
216
333
  const lines = jobs.map((j) => {
217
334
  const status = j.enabled ? "enabled" : "disabled";
218
- const lastRun = j.lastRunAt ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
335
+ const lastRun = j.lastRunAt
336
+ ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
337
+ : "never";
219
338
  const v = validateCronExpression(j.schedule, j.timezone);
220
- const nextRun = v.next ? new Date(v.next).toISOString().slice(0, 16).replace("T", " ") : "unknown";
339
+ const nextRun = v.next
340
+ ? new Date(v.next).toISOString().slice(0, 16).replace("T", " ")
341
+ : "unknown";
221
342
  return [
222
- `- ${j.name} (${status})`, ` ID: ${j.id}`,
343
+ `- ${j.name} (${status})`,
344
+ ` ID: ${j.id}`,
223
345
  ` Schedule: ${j.schedule}${j.timezone ? ` (${j.timezone})` : ""}`,
224
- ` Type: ${j.type}`, ` Content: ${j.content.slice(0, 100)}${j.content.length > 100 ? "..." : ""}`,
346
+ ` Type: ${j.type}`,
347
+ ` Content: ${j.content.slice(0, 100)}${j.content.length > 100 ? "..." : ""}`,
225
348
  ` Runs: ${j.runCount} | Last: ${lastRun} | Next: ${nextRun}`,
226
349
  ].join("\n");
227
350
  });
228
- return { ok: true, text: `Cron jobs (${jobs.length}):\n\n${lines.join("\n\n")}` };
351
+ return {
352
+ ok: true,
353
+ text: `Cron jobs (${jobs.length}):\n\n${lines.join("\n\n")}`,
354
+ };
229
355
  }
230
356
 
231
357
  case "edit_cron_job": {
@@ -233,22 +359,31 @@ export async function handleSharedAction(
233
359
  if (!jobId) return { ok: false, error: "Missing job_id" };
234
360
  const job = getCronJob(jobId);
235
361
  if (!job) return { ok: false, error: `Job ${jobId} not found` };
236
- if (job.chatId !== String(chatId)) return { ok: false, error: "Job belongs to a different chat" };
362
+ if (job.chatId !== String(chatId))
363
+ return { ok: false, error: "Job belongs to a different chat" };
237
364
 
238
365
  const updates: Record<string, unknown> = {};
239
366
  if (body.name !== undefined) updates.name = String(body.name);
240
367
  if (body.content !== undefined) updates.content = String(body.content);
241
368
  if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
242
369
  if (body.type !== undefined) updates.type = String(body.type);
243
- if (body.timezone !== undefined) updates.timezone = body.timezone ? String(body.timezone) : undefined;
370
+ if (body.timezone !== undefined)
371
+ updates.timezone = body.timezone ? String(body.timezone) : undefined;
244
372
  if (body.schedule !== undefined) {
245
- const v = validateCronExpression(String(body.schedule), (updates.timezone as string | undefined) ?? job.timezone);
246
- if (!v.valid) return { ok: false, error: `Invalid cron expression: ${v.error}` };
373
+ const v = validateCronExpression(
374
+ String(body.schedule),
375
+ (updates.timezone as string | undefined) ?? job.timezone,
376
+ );
377
+ if (!v.valid)
378
+ return { ok: false, error: `Invalid cron expression: ${v.error}` };
247
379
  updates.schedule = String(body.schedule);
248
380
  }
249
381
 
250
382
  const updated = updateCronJob(jobId, updates);
251
- return { ok: true, text: `Updated job "${updated?.name ?? jobId}". Fields changed: ${Object.keys(updates).join(", ")}` };
383
+ return {
384
+ ok: true,
385
+ text: `Updated job "${updated?.name ?? jobId}". Fields changed: ${Object.keys(updates).join(", ")}`,
386
+ };
252
387
  }
253
388
 
254
389
  case "delete_cron_job": {
@@ -256,7 +391,8 @@ export async function handleSharedAction(
256
391
  if (!jobId) return { ok: false, error: "Missing job_id" };
257
392
  const job = getCronJob(jobId);
258
393
  if (!job) return { ok: false, error: `Job ${jobId} not found` };
259
- if (job.chatId !== String(chatId)) return { ok: false, error: "Job belongs to a different chat" };
394
+ if (job.chatId !== String(chatId))
395
+ return { ok: false, error: "Job belongs to a different chat" };
260
396
  deleteCronJob(jobId);
261
397
  return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
262
398
  }
@@ -13,6 +13,7 @@ import {
13
13
  type IncomingMessage,
14
14
  type ServerResponse,
15
15
  } from "node:http";
16
+ import pRetry, { AbortError } from "p-retry";
16
17
  import { classify } from "./errors.js";
17
18
  import { getActiveCount } from "./dispatcher.js";
18
19
  import { getHealthStatus } from "../util/watchdog.js";
@@ -24,27 +25,51 @@ import type { FrontendActionHandler } from "./types.js";
24
25
 
25
26
  // ── Per-chat context state ───────────────────────────────────────────────────
26
27
 
27
- type ChatContext = { refCount: number; messagesSent: number; stringId?: string };
28
+ type ChatContext = {
29
+ refCount: number;
30
+ messagesSent: number;
31
+ stringId?: string;
32
+ };
28
33
 
29
34
  // ── Retry helper (stateless — standalone export) ─────────────────────────────
30
35
 
36
+ /**
37
+ * Retry a function up to 3 times with classified error inspection.
38
+ * Non-retryable errors (auth, bad_request, context_length) are thrown immediately.
39
+ * Uses p-retry for proper exponential backoff with jitter.
40
+ */
31
41
  export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
32
- let lastError: unknown;
33
- for (let attempt = 0; attempt < 3; attempt++) {
34
- try {
35
- return await fn();
36
- } catch (err) {
37
- lastError = err;
38
- const classified = classify(err);
39
- if (!classified.retryable) throw err;
40
- if (attempt < 2) {
41
- const delayMs = classified.retryAfterMs ?? 1000 * Math.pow(2, attempt);
42
- log("gateway", `Retry ${attempt + 1}/3 (${classified.reason}) after ${delayMs}ms`);
43
- await new Promise((r) => setTimeout(r, delayMs));
42
+ return pRetry(
43
+ async (attempt) => {
44
+ try {
45
+ return await fn();
46
+ } catch (err) {
47
+ const classified = classify(err);
48
+ if (!classified.retryable) {
49
+ // Wrap in AbortError to prevent further retries
50
+ throw new AbortError(classified);
51
+ }
52
+ const delayMs =
53
+ classified.retryAfterMs ?? 1000 * Math.pow(2, attempt - 1);
54
+ log(
55
+ "gateway",
56
+ `Retry ${attempt}/3 (${classified.reason}) after ${delayMs}ms`,
57
+ );
58
+ throw classified; // rethrow to trigger p-retry delay
44
59
  }
45
- }
46
- }
47
- throw lastError;
60
+ },
61
+ {
62
+ retries: 2, // 3 total attempts
63
+ minTimeout: 1000,
64
+ maxTimeout: 60_000,
65
+ factor: 2,
66
+ onFailedAttempt: (err) => {
67
+ if (err.retriesLeft === 0) {
68
+ logError("gateway", `All retries exhausted: ${err.error.message}`);
69
+ }
70
+ },
71
+ },
72
+ );
48
73
  }
49
74
 
50
75
  // ── Gateway class ────────────────────────────────────────────────────────────
@@ -57,7 +82,7 @@ export class Gateway {
57
82
 
58
83
  // ── Frontend handler registration ────────────────────────────────────────
59
84
 
60
- setFrontendHandler(handler: FrontendActionHandler): void {
85
+ setFrontendHandler(handler: FrontendActionHandler | null): void {
61
86
  this.frontendHandler = handler;
62
87
  }
63
88
 
@@ -67,10 +92,16 @@ export class Gateway {
67
92
  const ctx = this.chatContexts.get(chatId);
68
93
  if (ctx) {
69
94
  ctx.refCount++;
70
- log("gateway", `Context ref++ for chat ${chatId} (refCount=${ctx.refCount})`);
95
+ log(
96
+ "gateway",
97
+ `Context ref++ for chat ${chatId} (refCount=${ctx.refCount})`,
98
+ );
71
99
  } else {
72
100
  this.chatContexts.set(chatId, { refCount: 1, messagesSent: 0, stringId });
73
- log("gateway", `Context acquired for chat ${chatId}${stringId ? ` (${stringId})` : ""}`);
101
+ log(
102
+ "gateway",
103
+ `Context acquired for chat ${chatId}${stringId ? ` (${stringId})` : ""}`,
104
+ );
74
105
  }
75
106
  }
76
107
 
@@ -87,7 +118,9 @@ export class Gateway {
87
118
  if (chatId === undefined) return;
88
119
  const parsed = typeof chatId === "number" ? chatId : Number(chatId);
89
120
  // For non-numeric IDs (e.g. Teams "19:abc..."), Number() returns NaN — look up by string
90
- const numId = !isNaN(parsed) ? parsed : this.findContextByStringId(String(chatId));
121
+ const numId = !isNaN(parsed)
122
+ ? parsed
123
+ : this.findContextByStringId(String(chatId));
91
124
  if (numId === null) return;
92
125
  const ctx = this.chatContexts.get(numId);
93
126
  if (!ctx) return;
@@ -128,9 +161,10 @@ export class Gateway {
128
161
  // then fall back to searching active contexts.
129
162
  const rawChatId = body._chatId ? String(body._chatId) : "";
130
163
  const numericId = Number(rawChatId);
131
- const chatId = !isNaN(numericId) && this.chatContexts.has(numericId)
132
- ? numericId
133
- : this.findContextByStringId(rawChatId);
164
+ const chatId =
165
+ !isNaN(numericId) && this.chatContexts.has(numericId)
166
+ ? numericId
167
+ : this.findContextByStringId(rawChatId);
134
168
  if (!chatId) {
135
169
  return { ok: false, error: "No active chat context" };
136
170
  }
@@ -153,14 +187,20 @@ export class Gateway {
153
187
  // Try plugin actions (loaded from external plugin packages)
154
188
  const pluginResult = await handlePluginAction(body, String(chatId));
155
189
  if (pluginResult) {
156
- logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms (plugin)`);
190
+ logDebug(
191
+ "gateway",
192
+ `${action} chat=${chatId} ${Date.now() - t0}ms (plugin)`,
193
+ );
157
194
  return pluginResult;
158
195
  }
159
196
 
160
197
  // Shared actions last — provides in-memory fallbacks for history, cron, etc.
161
198
  const shared = await handleSharedAction(body, chatId);
162
199
  if (shared) {
163
- logDebug("gateway", `${action} chat=${chatId} ${Date.now() - t0}ms (shared)`);
200
+ logDebug(
201
+ "gateway",
202
+ `${action} chat=${chatId} ${Date.now() - t0}ms (shared)`,
203
+ );
164
204
  return shared;
165
205
  }
166
206
 
@@ -182,19 +222,22 @@ export class Gateway {
182
222
  if (req.method === "GET" && req.url === "/health") {
183
223
  const w = getHealthStatus();
184
224
  res.writeHead(200, { "Content-Type": "application/json" });
185
- res.end(JSON.stringify({
186
- ok: w.healthy,
187
- uptime: Math.round(process.uptime()),
188
- memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
189
- bridge: { activeChats: this.chatContexts.size },
190
- queue: getActiveCount(),
191
- sessions: getActiveSessionCount(),
192
- messages: w.totalMessagesProcessed,
193
- errors: w.recentErrorCount,
194
- lastActivity: w.msSinceLastMessage < 60000
195
- ? "just now"
196
- : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
197
- }));
225
+ res.end(
226
+ JSON.stringify({
227
+ ok: w.healthy,
228
+ uptime: Math.round(process.uptime()),
229
+ memory: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
230
+ bridge: { activeChats: this.chatContexts.size },
231
+ queue: getActiveCount(),
232
+ sessions: getActiveSessionCount(),
233
+ messages: w.totalMessagesProcessed,
234
+ errors: w.recentErrorCount,
235
+ lastActivity:
236
+ w.msSinceLastMessage < 60000
237
+ ? "just now"
238
+ : `${Math.round(w.msSinceLastMessage / 60000)}m ago`,
239
+ }),
240
+ );
198
241
  return;
199
242
  }
200
243
 
@@ -216,9 +259,11 @@ export class Gateway {
216
259
  return;
217
260
  }
218
261
  const result = await this.handleAction(body);
262
+ const json = JSON.stringify(result);
219
263
  res.writeHead(200, { "Content-Type": "application/json" });
220
- res.end(JSON.stringify(result));
264
+ res.end(json);
221
265
  } catch (err) {
266
+ if (res.headersSent) return;
222
267
  const msg = err instanceof Error ? err.message : String(err);
223
268
  res.writeHead(500, { "Content-Type": "application/json" });
224
269
  res.end(JSON.stringify({ ok: false, error: msg }));
@@ -251,8 +296,15 @@ export class Gateway {
251
296
 
252
297
  async stop(): Promise<void> {
253
298
  return new Promise((resolve) => {
254
- if (!this.server) { resolve(); return; }
255
- this.server.close(() => { this.server = null; this.port = 0; resolve(); });
299
+ if (!this.server) {
300
+ resolve();
301
+ return;
302
+ }
303
+ this.server.close(() => {
304
+ this.server = null;
305
+ this.port = 0;
306
+ resolve();
307
+ });
256
308
  });
257
309
  }
258
310
  }