@iletai/nzb 1.7.3 → 1.7.4

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.
@@ -77,8 +77,23 @@ app.get("/stream", (req, res) => {
77
77
  sseClients.set(connectionId, res);
78
78
  // Heartbeat to keep connection alive
79
79
  const heartbeat = setInterval(() => {
80
- res.write(`:ping\n\n`);
80
+ if (res.writableEnded || res.closed) {
81
+ clearInterval(heartbeat);
82
+ sseClients.delete(connectionId);
83
+ return;
84
+ }
85
+ try {
86
+ res.write(`:ping\n\n`);
87
+ }
88
+ catch {
89
+ clearInterval(heartbeat);
90
+ sseClients.delete(connectionId);
91
+ }
81
92
  }, 20_000);
93
+ res.on("error", () => {
94
+ clearInterval(heartbeat);
95
+ sseClients.delete(connectionId);
96
+ });
82
97
  req.on("close", () => {
83
98
  clearInterval(heartbeat);
84
99
  sseClients.delete(connectionId);
@@ -196,6 +211,13 @@ app.post("/send-photo", async (req, res) => {
196
211
  res.status(500).json({ error: msg });
197
212
  }
198
213
  });
214
+ // Global error handler — catch unhandled Express errors
215
+ app.use((err, _req, res, _next) => {
216
+ console.error("[nzb] Express error:", err.message);
217
+ if (!res.headersSent) {
218
+ res.status(500).json({ error: "Internal server error" });
219
+ }
220
+ });
199
221
  export function startApiServer() {
200
222
  return new Promise((resolve, reject) => {
201
223
  const server = app.listen(config.apiPort, "127.0.0.1", () => {
@@ -155,6 +155,8 @@ function startHealthCheck() {
155
155
  return;
156
156
  if (healthCheckRunning)
157
157
  return;
158
+ if (processing)
159
+ return; // Don't interfere while processing messages
158
160
  healthCheckRunning = true;
159
161
  try {
160
162
  const state = copilotClient.getState();
@@ -188,17 +190,17 @@ export function stopHealthCheck() {
188
190
  function startWorkerReaper() {
189
191
  if (workerReaperTimer)
190
192
  return;
191
- workerReaperTimer = setInterval(() => {
193
+ workerReaperTimer = setInterval(async () => {
192
194
  const maxAge = config.workerTimeoutMs * 2;
193
195
  const now = Date.now();
194
196
  for (const [name, worker] of workers) {
195
197
  if (worker.startedAt && now - worker.startedAt > maxAge) {
196
198
  console.log(`[nzb] Reaping stuck worker '${name}' (age: ${formatAge(worker.startedAt)})`);
197
199
  try {
198
- worker.session.disconnect().catch(() => { });
200
+ await withTimeout(worker.session.disconnect(), 5_000, `reaper: worker '${name}'`);
199
201
  }
200
- catch {
201
- // Session may already be destroyed
202
+ catch (err) {
203
+ console.error(`[nzb] Reaper: worker '${name}' disconnect failed:`, err instanceof Error ? err.message : err);
202
204
  }
203
205
  workers.delete(name);
204
206
  feedBackgroundResult(name, `⚠ Worker '${name}' was automatically killed after exceeding timeout.`);
@@ -522,7 +524,10 @@ async function processQueue() {
522
524
  }
523
525
  // Re-check for messages that arrived during the last executeOnSession call
524
526
  if (messageQueue.length > 0) {
525
- void processQueue();
527
+ processQueue().catch((err) => {
528
+ console.error("[nzb] processQueue re-check failed:", err instanceof Error ? err.message : err);
529
+ processing = false;
530
+ });
526
531
  }
527
532
  }
528
533
  function isRecoverableError(err) {
@@ -560,104 +565,133 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
560
565
  const sourceChannel = source.type === "telegram" ? "telegram" : source.type === "tui" ? "tui" : undefined;
561
566
  // Enqueue with priority — user messages go before background messages
562
567
  void (async () => {
563
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
564
- try {
565
- const finalContent = await new Promise((resolve, reject) => {
566
- const item = {
567
- prompt: taggedPrompt,
568
- attachments,
569
- callback,
570
- onToolEvent,
571
- onUsage,
572
- sourceChannel,
573
- resolve,
574
- reject,
575
- };
576
- if (source.type === "background") {
577
- // Background results go to the back of the queue
578
- messageQueue.push(item);
579
- }
580
- else {
581
- // User messages inserted before any background messages (priority)
582
- const bgIndex = messageQueue.findIndex(isBackgroundMessage);
583
- if (bgIndex >= 0) {
584
- messageQueue.splice(bgIndex, 0, item);
568
+ // Safety timeout for entire message processing chain.
569
+ // Uses a flag to prevent double-callback if timeout fires while processing completes.
570
+ const GLOBAL_MSG_TIMEOUT_MS = 300_000; // 5 minutes
571
+ let globalTimedOut = false;
572
+ const globalTimer = setTimeout(() => {
573
+ globalTimedOut = true;
574
+ console.error("[nzb] Global message processing timeout (5 min). Force-failing.");
575
+ Promise.resolve(callback("Error: Message processing timed out after 5 minutes. Please try again.", true)).catch(() => { });
576
+ }, GLOBAL_MSG_TIMEOUT_MS);
577
+ try {
578
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
579
+ try {
580
+ const finalContent = await new Promise((resolve, reject) => {
581
+ const item = {
582
+ prompt: taggedPrompt,
583
+ attachments,
584
+ callback,
585
+ onToolEvent,
586
+ onUsage,
587
+ sourceChannel,
588
+ resolve,
589
+ reject,
590
+ };
591
+ if (source.type === "background") {
592
+ // Background results go to the back of the queue
593
+ messageQueue.push(item);
585
594
  }
586
595
  else {
587
- messageQueue.push(item);
596
+ // User messages inserted before any background messages (priority)
597
+ const bgIndex = messageQueue.findIndex(isBackgroundMessage);
598
+ if (bgIndex >= 0) {
599
+ messageQueue.splice(bgIndex, 0, item);
600
+ }
601
+ else {
602
+ messageQueue.push(item);
603
+ }
588
604
  }
605
+ processQueue();
606
+ });
607
+ // Deliver response to user FIRST, then log best-effort
608
+ try {
609
+ logMessage("out", sourceLabel, finalContent);
610
+ }
611
+ catch {
612
+ /* best-effort */
613
+ }
614
+ // Log both sides of the conversation before delivery so we have the row ID
615
+ let assistantLogId;
616
+ try {
617
+ const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
618
+ logConversation(logRole, prompt, sourceLabel, telegramMsgId);
619
+ }
620
+ catch {
621
+ /* best-effort */
589
622
  }
590
- processQueue();
591
- });
592
- // Deliver response to user FIRST, then log best-effort
593
- try {
594
- logMessage("out", sourceLabel, finalContent);
595
- }
596
- catch {
597
- /* best-effort */
598
- }
599
- // Log both sides of the conversation before delivery so we have the row ID
600
- let assistantLogId;
601
- try {
602
- const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
603
- logConversation(logRole, prompt, sourceLabel, telegramMsgId);
604
- }
605
- catch {
606
- /* best-effort */
607
- }
608
- try {
609
- assistantLogId = logConversation("assistant", finalContent, sourceLabel);
610
- }
611
- catch {
612
- /* best-effort */
613
- }
614
- await callback(finalContent, true, { assistantLogId });
615
- // Auto-continue: if the response was cut short by timeout, automatically
616
- // send a follow-up "Continue" message so the user doesn't have to
617
- if (finalContent.includes("⏱ Response was cut short (timeout)") && _autoContinueCount < MAX_AUTO_CONTINUE) {
618
- console.log(`[nzb] Auto-continuing after timeout (${_autoContinueCount + 1}/${MAX_AUTO_CONTINUE})…`);
619
- await sleep(1000);
620
- void sendToOrchestrator("Continue from where you left off. Do not repeat what was already said.", source, callback, onToolEvent, onUsage, _autoContinueCount + 1);
621
- }
622
- return;
623
- }
624
- catch (err) {
625
- const msg = err instanceof Error ? err.message : String(err);
626
- // Don't retry cancelled messages
627
- if (/cancelled|abort/i.test(msg)) {
628
- return;
629
- }
630
- // Vision not supported — strip attachments and retry with text-only prompt.
631
- // executeOnSession already destroyed the tainted session.
632
- if (/not supported for vision/i.test(msg)) {
633
- console.log(`[nzb] Vision not supported — retrying without attachments`);
634
- attachments = undefined;
635
- taggedPrompt =
636
- `[System: The current model '${config.copilotModel}' does not support image/vision analysis. ` +
637
- `The image path is already included in the user's message below. ` +
638
- `Please inform the user that the current model doesn't support direct image analysis, ` +
639
- `and suggest switching to a vision-capable model (e.g. gpt-4o, claude-sonnet-4, gemini-2.0-flash) ` +
640
- `using the /model command.]\n\n${taggedPrompt}`;
641
- continue;
642
- }
643
- if (isRecoverableError(err) && attempt < MAX_RETRIES) {
644
- const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
645
- console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
646
- await sleep(delay);
647
- // Reset client before retry in case the connection is stale
648
623
  try {
649
- await ensureClient();
624
+ assistantLogId = logConversation("assistant", finalContent, sourceLabel);
650
625
  }
651
626
  catch {
652
- /* will fail again on next attempt */
627
+ /* best-effort */
653
628
  }
654
- continue;
629
+ try {
630
+ if (!globalTimedOut) {
631
+ await callback(finalContent, true, { assistantLogId });
632
+ }
633
+ }
634
+ catch (callbackErr) {
635
+ console.error("[nzb] Callback error after successful response:", callbackErr instanceof Error ? callbackErr.message : callbackErr);
636
+ }
637
+ // Auto-continue: if the response was cut short by timeout, automatically
638
+ // send a follow-up "Continue" message so the user doesn't have to
639
+ if (finalContent.includes("⏱ Response was cut short (timeout)") && _autoContinueCount < MAX_AUTO_CONTINUE) {
640
+ console.log(`[nzb] Auto-continuing after timeout (${_autoContinueCount + 1}/${MAX_AUTO_CONTINUE})…`);
641
+ await sleep(1000);
642
+ void sendToOrchestrator("Continue from where you left off. Do not repeat what was already said.", source, callback, onToolEvent, onUsage, _autoContinueCount + 1);
643
+ }
644
+ return;
645
+ }
646
+ catch (err) {
647
+ const msg = err instanceof Error ? err.message : String(err);
648
+ // Don't retry cancelled messages
649
+ if (/cancelled|abort/i.test(msg)) {
650
+ if (!globalTimedOut) {
651
+ try {
652
+ await callback("Request was cancelled.", true);
653
+ }
654
+ catch { /* best-effort */ }
655
+ }
656
+ return;
657
+ }
658
+ // Vision not supported — strip attachments and retry with text-only prompt.
659
+ // executeOnSession already destroyed the tainted session.
660
+ if (/not supported for vision/i.test(msg)) {
661
+ console.log(`[nzb] Vision not supported — retrying without attachments`);
662
+ attachments = undefined;
663
+ taggedPrompt =
664
+ `[System: The current model '${config.copilotModel}' does not support image/vision analysis. ` +
665
+ `The image path is already included in the user's message below. ` +
666
+ `Please inform the user that the current model doesn't support direct image analysis, ` +
667
+ `and suggest switching to a vision-capable model (e.g. gpt-4o, claude-sonnet-4, gemini-2.0-flash) ` +
668
+ `using the /model command.]\n\n${taggedPrompt}`;
669
+ continue;
670
+ }
671
+ if (isRecoverableError(err) && attempt < MAX_RETRIES) {
672
+ const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
673
+ console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
674
+ await sleep(delay);
675
+ // Reset client before retry in case the connection is stale
676
+ try {
677
+ await ensureClient();
678
+ }
679
+ catch {
680
+ /* will fail again on next attempt */
681
+ }
682
+ continue;
683
+ }
684
+ console.error(`[nzb] Error processing message: ${msg}`);
685
+ if (!globalTimedOut) {
686
+ await callback(`Error: ${msg}`, true);
687
+ }
688
+ return;
655
689
  }
656
- console.error(`[nzb] Error processing message: ${msg}`);
657
- await callback(`Error: ${msg}`, true);
658
- return;
659
690
  }
660
691
  }
692
+ finally {
693
+ clearTimeout(globalTimer);
694
+ }
661
695
  })().catch((err) => {
662
696
  console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
663
697
  });
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { config, persistModel } from "../config.js";
7
7
  import { SESSIONS_DIR } from "../paths.js";
8
8
  import { getDb } from "../store/db.js";
9
+ import { withTimeout } from "../utils.js";
9
10
  import { addMemory, removeMemory, searchMemories } from "../store/memory.js";
10
11
  import { createSkill, listSkills, removeSkill } from "./skills.js";
11
12
  function isTimeoutError(err) {
@@ -111,7 +112,9 @@ export function createTools(deps) {
111
112
  })
112
113
  .finally(() => {
113
114
  // Auto-destroy background workers after completion to free memory (~400MB per worker)
114
- session.disconnect().catch(() => { });
115
+ withTimeout(session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
116
+ console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
117
+ });
115
118
  deps.workers.delete(args.name);
116
119
  try {
117
120
  getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
@@ -162,7 +165,9 @@ export function createTools(deps) {
162
165
  })
163
166
  .finally(() => {
164
167
  // Auto-destroy after each send_to_worker dispatch to free memory
165
- worker.session.disconnect().catch(() => { });
168
+ withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
169
+ console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
170
+ });
166
171
  deps.workers.delete(args.name);
167
172
  try {
168
173
  getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
@@ -210,10 +215,10 @@ export function createTools(deps) {
210
215
  return `No worker named '${args.name}'.`;
211
216
  }
212
217
  try {
213
- await worker.session.disconnect();
218
+ await withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`);
214
219
  }
215
- catch {
216
- // Session may already be gone
220
+ catch (err) {
221
+ console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
217
222
  }
218
223
  deps.workers.delete(args.name);
219
224
  const db = getDb();
@@ -232,7 +237,9 @@ export function createTools(deps) {
232
237
  if (!worker)
233
238
  return `No worker found with name '${args.name}'.`;
234
239
  try {
235
- worker.session.disconnect().catch(() => { });
240
+ withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
241
+ console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
242
+ });
236
243
  }
237
244
  catch {
238
245
  // Session may already be destroyed
@@ -347,7 +354,9 @@ export function createTools(deps) {
347
354
  deps.onWorkerComplete(member.name, errMsg);
348
355
  })
349
356
  .finally(() => {
350
- session.disconnect().catch(() => { });
357
+ withTimeout(session.disconnect(), 5_000, `worker '${member.name}' disconnect`).catch((err) => {
358
+ console.error(`[nzb] Worker '${member.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
359
+ });
351
360
  deps.workers.delete(member.name);
352
361
  try {
353
362
  getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(member.name);
package/dist/store/db.js CHANGED
@@ -8,6 +8,7 @@ export function getDb() {
8
8
  ensureNZBHome();
9
9
  db = new Database(DB_PATH);
10
10
  db.pragma("journal_mode = WAL");
11
+ db.pragma("busy_timeout = 5000");
11
12
  db.exec(`
12
13
  CREATE TABLE IF NOT EXISTS worker_sessions (
13
14
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -25,6 +25,8 @@ const startedAt = Date.now();
25
25
  const INITIAL_POLL_RETRY_DELAY = 5000;
26
26
  const MAX_POLL_RETRY_DELAY = 300_000; // 5 minutes
27
27
  let pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
28
+ let consecutivePollingFailures = 0;
29
+ const MAX_CONSECUTIVE_POLLING_FAILURES = 10;
28
30
  // Direct-connection HTTPS agent for Telegram API requests.
29
31
  // This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
30
32
  // modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
@@ -177,15 +179,21 @@ export async function startBot() {
177
179
  ...(savedOffset ? { offset: savedOffset + 1 } : {}),
178
180
  onStart: () => {
179
181
  console.log("[nzb] Telegram bot connected");
182
+ consecutivePollingFailures = 0;
180
183
  pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
181
184
  void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
182
185
  },
183
186
  })
184
187
  .catch(async (err) => {
188
+ consecutivePollingFailures++;
185
189
  if (err?.error_code === 401) {
186
190
  console.error("[nzb] Warning: Telegram bot token is invalid or expired. Run 'nzb setup' and re-enter your bot token from @BotFather.");
187
191
  return; // Unrecoverable — don't retry
188
192
  }
193
+ if (consecutivePollingFailures >= MAX_CONSECUTIVE_POLLING_FAILURES) {
194
+ console.error(`[nzb] Telegram polling failed ${consecutivePollingFailures} consecutive times. Stopping retry attempts. Restart NZB to try again.`);
195
+ return;
196
+ }
189
197
  if (err?.error_code === 409) {
190
198
  console.error(`[nzb] Warning: Telegram polling conflict (409). Restarting polling in ${pollRetryDelay / 1000}s...`);
191
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "NZB — a personal AI assistant for developers, built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "nzb": "dist/cli.js"