@iletai/nzb 1.7.0 → 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.
@@ -1,7 +1,11 @@
1
1
  import { approveAll } from "@github/copilot-sdk";
2
2
  import { config, DEFAULT_MODEL } from "../config.js";
3
3
  import { SESSIONS_DIR } from "../paths.js";
4
- import { completeTeam, deleteState, getMemorySummary, getRecentConversation, getState, logConversation, setState, updateTeamMemberResult, } from "../store/db.js";
4
+ import { deleteState, getState, setState } from "../store/db.js";
5
+ import { getRecentConversation, logConversation } from "../store/conversation.js";
6
+ import { getMemorySummary } from "../store/memory.js";
7
+ import { completeTeam, updateTeamMemberResult } from "../store/team-store.js";
8
+ import { formatAge, withTimeout } from "../utils.js";
5
9
  import { resetClient } from "./client.js";
6
10
  import { loadMcpConfig } from "./mcp-config.js";
7
11
  import { getSkillDirectories } from "./skills.js";
@@ -27,10 +31,13 @@ let copilotClient;
27
31
  const workers = new Map();
28
32
  const teams = new Map();
29
33
  let healthCheckTimer;
34
+ let workerReaperTimer;
30
35
  // Persistent orchestrator session
31
36
  let orchestratorSession;
32
37
  // Coalesces concurrent ensureOrchestratorSession calls
33
38
  let sessionCreatePromise;
39
+ // Tracks when the orchestrator session was created for TTL enforcement
40
+ let sessionCreatedAt;
34
41
  // Tracks in-flight context recovery injection so we don't race with real messages
35
42
  let recoveryInjectionPromise;
36
43
  const messageQueue = [];
@@ -60,6 +67,7 @@ function getSessionConfig() {
60
67
  workerNotifyFn(event, channel);
61
68
  }
62
69
  },
70
+ getCurrentSourceChannel,
63
71
  });
64
72
  cachedToolsClientRef = copilotClient;
65
73
  }
@@ -138,27 +146,37 @@ async function ensureClient() {
138
146
  return resetPromise;
139
147
  }
140
148
  /** Start periodic health check that proactively reconnects the client. */
149
+ let healthCheckRunning = false;
141
150
  function startHealthCheck() {
142
151
  if (healthCheckTimer)
143
152
  return;
144
153
  healthCheckTimer = setInterval(async () => {
145
154
  if (!copilotClient)
146
155
  return;
156
+ if (healthCheckRunning)
157
+ return;
158
+ if (processing)
159
+ return; // Don't interfere while processing messages
160
+ healthCheckRunning = true;
147
161
  try {
148
162
  const state = copilotClient.getState();
149
163
  if (state !== "connected") {
150
164
  console.log(`[nzb] Health check: client state is '${state}', resetting…`);
151
165
  const previousClient = copilotClient;
152
- await ensureClient();
166
+ await withTimeout(ensureClient(), 15_000, "health check");
153
167
  // Only invalidate session if the underlying client actually changed
154
168
  if (copilotClient !== previousClient) {
155
169
  orchestratorSession = undefined;
170
+ sessionCreatedAt = undefined;
156
171
  }
157
172
  }
158
173
  }
159
174
  catch (err) {
160
175
  console.error(`[nzb] Health check error:`, err instanceof Error ? err.message : err);
161
176
  }
177
+ finally {
178
+ healthCheckRunning = false;
179
+ }
162
180
  }, HEALTH_CHECK_INTERVAL_MS);
163
181
  }
164
182
  /** Stop the periodic health check timer. Call during shutdown. */
@@ -168,17 +186,64 @@ export function stopHealthCheck() {
168
186
  healthCheckTimer = undefined;
169
187
  }
170
188
  }
189
+ /** Periodically kills workers that have exceeded 2× their configured timeout. */
190
+ function startWorkerReaper() {
191
+ if (workerReaperTimer)
192
+ return;
193
+ workerReaperTimer = setInterval(async () => {
194
+ const maxAge = config.workerTimeoutMs * 2;
195
+ const now = Date.now();
196
+ for (const [name, worker] of workers) {
197
+ if (worker.startedAt && now - worker.startedAt > maxAge) {
198
+ console.log(`[nzb] Reaping stuck worker '${name}' (age: ${formatAge(worker.startedAt)})`);
199
+ try {
200
+ await withTimeout(worker.session.disconnect(), 5_000, `reaper: worker '${name}'`);
201
+ }
202
+ catch (err) {
203
+ console.error(`[nzb] Reaper: worker '${name}' disconnect failed:`, err instanceof Error ? err.message : err);
204
+ }
205
+ workers.delete(name);
206
+ feedBackgroundResult(name, `⚠ Worker '${name}' was automatically killed after exceeding timeout.`);
207
+ }
208
+ }
209
+ }, 5 * 60 * 1000);
210
+ workerReaperTimer.unref();
211
+ }
212
+ const SESSION_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
171
213
  /** Create or resume the persistent orchestrator session. */
172
214
  async function ensureOrchestratorSession() {
173
- if (orchestratorSession)
174
- return orchestratorSession;
215
+ if (orchestratorSession) {
216
+ // Validate session is still usable — check client connectivity
217
+ try {
218
+ const clientState = copilotClient?.getState?.();
219
+ if (clientState && clientState !== "connected") {
220
+ console.log(`[nzb] Session stale (client state: ${clientState}), recreating…`);
221
+ orchestratorSession = undefined;
222
+ sessionCreatedAt = undefined;
223
+ }
224
+ }
225
+ catch {
226
+ console.log("[nzb] Session validation failed, recreating…");
227
+ orchestratorSession = undefined;
228
+ sessionCreatedAt = undefined;
229
+ }
230
+ // Enforce session TTL
231
+ if (sessionCreatedAt && Date.now() - sessionCreatedAt > SESSION_MAX_AGE_MS) {
232
+ console.log("[nzb] Session TTL expired, recreating…");
233
+ orchestratorSession = undefined;
234
+ sessionCreatedAt = undefined;
235
+ }
236
+ if (orchestratorSession)
237
+ return orchestratorSession;
238
+ }
175
239
  // Coalesce concurrent callers — wait for an in-flight creation
176
240
  if (sessionCreatePromise)
177
241
  return sessionCreatePromise;
178
- sessionCreatePromise = createOrResumeSession();
242
+ sessionCreatePromise = withTimeout(createOrResumeSession(), 30_000, "session create/resume");
179
243
  try {
180
244
  const session = await sessionCreatePromise;
181
245
  orchestratorSession = session;
246
+ sessionCreatedAt = Date.now();
182
247
  return session;
183
248
  }
184
249
  finally {
@@ -250,7 +315,7 @@ async function createOrResumeSession() {
250
315
  // Recover conversation context if available (session was lost, not first run)
251
316
  // Runs concurrently but is awaited before any real message is sent on the session
252
317
  const recentHistory = getRecentConversation(10);
253
- if (recentHistory) {
318
+ if (!recoveryInjectionPromise && recentHistory) {
254
319
  console.log(`[nzb] Injecting recent conversation context into new session`);
255
320
  recoveryInjectionPromise = session
256
321
  .sendAndWait({
@@ -290,6 +355,7 @@ export async function initOrchestrator(client) {
290
355
  console.log(`[nzb] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
291
356
  console.log(`[nzb] Persistent session mode — conversation history maintained by SDK`);
292
357
  startHealthCheck();
358
+ startWorkerReaper();
293
359
  // Eagerly create/resume the orchestrator session
294
360
  try {
295
361
  await ensureOrchestratorSession();
@@ -303,53 +369,85 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
303
369
  const session = await ensureOrchestratorSession();
304
370
  // Wait for any in-flight context recovery injection to finish before sending
305
371
  if (recoveryInjectionPromise) {
306
- console.log(`[nzb] Waiting for context recovery injection to complete before sending…`);
307
- await recoveryInjectionPromise;
372
+ console.log("[nzb] Waiting for context recovery…");
373
+ await withTimeout(recoveryInjectionPromise, 25_000, "recovery injection wait").catch(() => {
374
+ console.log("[nzb] Recovery injection wait timed out, proceeding anyway");
375
+ });
308
376
  }
309
377
  currentCallback = callback;
310
378
  let accumulated = "";
311
379
  let toolCallExecuted = false;
312
380
  const unsubToolStart = session.on("tool.execution_start", (event) => {
313
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
314
- const args = event?.data?.arguments;
315
- const detail = args?.description ||
316
- args?.command?.slice(0, 80) ||
317
- args?.intent ||
318
- args?.pattern ||
319
- args?.prompt?.slice(0, 80) ||
320
- undefined;
321
- onToolEvent?.({ type: "tool_start", toolName, detail });
381
+ try {
382
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
383
+ const args = event?.data?.arguments;
384
+ const detail = args?.description ||
385
+ args?.command?.slice(0, 80) ||
386
+ args?.intent ||
387
+ args?.pattern ||
388
+ args?.prompt?.slice(0, 80) ||
389
+ undefined;
390
+ onToolEvent?.({ type: "tool_start", toolName, detail });
391
+ }
392
+ catch (err) {
393
+ console.error("[nzb] Error in tool.execution_start listener:", err instanceof Error ? err.message : err);
394
+ }
322
395
  });
323
396
  const unsubToolDone = session.on("tool.execution_complete", (event) => {
324
- toolCallExecuted = true;
325
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
326
- onToolEvent?.({ type: "tool_complete", toolName });
397
+ try {
398
+ toolCallExecuted = true;
399
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
400
+ onToolEvent?.({ type: "tool_complete", toolName });
401
+ }
402
+ catch (err) {
403
+ console.error("[nzb] Error in tool.execution_complete listener:", err instanceof Error ? err.message : err);
404
+ }
327
405
  });
328
406
  const unsubDelta = session.on("assistant.message_delta", (event) => {
329
- // After a tool call completes, ensure a line break separates the text blocks
330
- // so they don't visually run together in the TUI.
331
- if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
332
- accumulated += "\n";
333
- }
334
- toolCallExecuted = false;
335
- accumulated += event.data.deltaContent;
336
- callback(accumulated, false);
407
+ try {
408
+ // After a tool call completes, ensure a line break separates the text blocks
409
+ // so they don't visually run together in the TUI.
410
+ if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
411
+ accumulated += "\n";
412
+ }
413
+ toolCallExecuted = false;
414
+ accumulated += event.data.deltaContent;
415
+ callback(accumulated, false);
416
+ }
417
+ catch (err) {
418
+ console.error("[nzb] Error in message_delta listener:", err instanceof Error ? err.message : err);
419
+ }
337
420
  });
338
421
  const unsubError = session.on("session.error", (event) => {
339
- const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
340
- console.error(`[nzb] Session error event: ${errMsg}`);
422
+ try {
423
+ const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
424
+ console.error(`[nzb] Session error event: ${errMsg}`);
425
+ }
426
+ catch (err) {
427
+ console.error("[nzb] Error in session.error listener:", err instanceof Error ? err.message : err);
428
+ }
341
429
  });
342
430
  const unsubPartialResult = session.on("tool.execution_partial_result", (event) => {
343
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
344
- const partialOutput = event?.data?.partialOutput || "";
345
- onToolEvent?.({ type: "tool_partial_result", toolName, detail: partialOutput });
431
+ try {
432
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
433
+ const partialOutput = event?.data?.partialOutput || "";
434
+ onToolEvent?.({ type: "tool_partial_result", toolName, detail: partialOutput });
435
+ }
436
+ catch (err) {
437
+ console.error("[nzb] Error in tool.execution_partial_result listener:", err instanceof Error ? err.message : err);
438
+ }
346
439
  });
347
440
  const unsubUsage = session.on("assistant.usage", (event) => {
348
- const inputTokens = event?.data?.inputTokens || 0;
349
- const outputTokens = event?.data?.outputTokens || 0;
350
- const model = event?.data?.model || undefined;
351
- const duration = event?.data?.duration || undefined;
352
- onUsage?.({ inputTokens, outputTokens, model, duration });
441
+ try {
442
+ const inputTokens = event?.data?.inputTokens || 0;
443
+ const outputTokens = event?.data?.outputTokens || 0;
444
+ const model = event?.data?.model || undefined;
445
+ const duration = event?.data?.duration || undefined;
446
+ onUsage?.({ inputTokens, outputTokens, model, duration });
447
+ }
448
+ catch (err) {
449
+ console.error("[nzb] Error in assistant.usage listener:", err instanceof Error ? err.message : err);
450
+ }
353
451
  });
354
452
  try {
355
453
  const sendPayload = { prompt };
@@ -364,6 +462,16 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
364
462
  }
365
463
  catch (err) {
366
464
  const msg = err instanceof Error ? err.message : String(err);
465
+ // Vision not supported — the session is now tainted with image data in its history,
466
+ // so ALL subsequent messages would fail. Force-recreate the session to recover.
467
+ // The retry (with stripped attachments) is handled by sendToOrchestrator's retry loop.
468
+ if (/not supported for vision/i.test(msg)) {
469
+ console.log(`[nzb] Model '${config.copilotModel}' does not support vision — destroying tainted session`);
470
+ orchestratorSession = undefined;
471
+ sessionCreatedAt = undefined;
472
+ deleteState(ORCHESTRATOR_SESSION_KEY);
473
+ throw err;
474
+ }
367
475
  // On timeout, deliver whatever was accumulated instead of retrying from scratch
368
476
  if (/timeout/i.test(msg) && accumulated.length > 0) {
369
477
  console.log(`[nzb] Timeout — delivering ${accumulated.length} chars of partial content`);
@@ -373,6 +481,7 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
373
481
  if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
374
482
  console.log(`[nzb] Session appears dead, will recreate: ${msg}`);
375
483
  orchestratorSession = undefined;
484
+ sessionCreatedAt = undefined;
376
485
  deleteState(ORCHESTRATOR_SESSION_KEY);
377
486
  }
378
487
  throw err;
@@ -396,19 +505,30 @@ async function processQueue() {
396
505
  return;
397
506
  }
398
507
  processing = true;
399
- while (messageQueue.length > 0) {
400
- const item = messageQueue.shift();
401
- currentSourceChannel = item.sourceChannel;
402
- try {
403
- const result = await executeOnSession(item.prompt, item.callback, item.onToolEvent, item.onUsage, item.attachments);
404
- item.resolve(result);
405
- }
406
- catch (err) {
407
- item.reject(err);
508
+ try {
509
+ while (messageQueue.length > 0) {
510
+ const item = messageQueue.shift();
511
+ currentSourceChannel = item.sourceChannel;
512
+ try {
513
+ const result = await executeOnSession(item.prompt, item.callback, item.onToolEvent, item.onUsage, item.attachments);
514
+ item.resolve(result);
515
+ }
516
+ catch (err) {
517
+ item.reject(err);
518
+ }
519
+ currentSourceChannel = undefined;
408
520
  }
409
- currentSourceChannel = undefined;
410
521
  }
411
- processing = false;
522
+ finally {
523
+ processing = false;
524
+ }
525
+ // Re-check for messages that arrived during the last executeOnSession call
526
+ if (messageQueue.length > 0) {
527
+ processQueue().catch((err) => {
528
+ console.error("[nzb] processQueue re-check failed:", err instanceof Error ? err.message : err);
529
+ processing = false;
530
+ });
531
+ }
412
532
  }
413
533
  function isRecoverableError(err) {
414
534
  const msg = err instanceof Error ? err.message : String(err);
@@ -445,94 +565,137 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
445
565
  const sourceChannel = source.type === "telegram" ? "telegram" : source.type === "tui" ? "tui" : undefined;
446
566
  // Enqueue with priority — user messages go before background messages
447
567
  void (async () => {
448
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
449
- try {
450
- const finalContent = await new Promise((resolve, reject) => {
451
- const item = {
452
- prompt: taggedPrompt,
453
- attachments,
454
- callback,
455
- onToolEvent,
456
- onUsage,
457
- sourceChannel,
458
- resolve,
459
- reject,
460
- };
461
- if (source.type === "background") {
462
- // Background results go to the back of the queue
463
- messageQueue.push(item);
464
- }
465
- else {
466
- // User messages inserted before any background messages (priority)
467
- const bgIndex = messageQueue.findIndex(isBackgroundMessage);
468
- if (bgIndex >= 0) {
469
- 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);
470
594
  }
471
595
  else {
472
- 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
+ }
473
604
  }
605
+ processQueue();
606
+ });
607
+ // Deliver response to user FIRST, then log best-effort
608
+ try {
609
+ logMessage("out", sourceLabel, finalContent);
474
610
  }
475
- processQueue();
476
- });
477
- // Deliver response to user FIRST, then log best-effort
478
- try {
479
- logMessage("out", sourceLabel, finalContent);
480
- }
481
- catch {
482
- /* best-effort */
483
- }
484
- // Log both sides of the conversation before delivery so we have the row ID
485
- let assistantLogId;
486
- try {
487
- const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
488
- logConversation(logRole, prompt, sourceLabel, telegramMsgId);
489
- }
490
- catch {
491
- /* best-effort */
492
- }
493
- try {
494
- assistantLogId = logConversation("assistant", finalContent, sourceLabel);
495
- }
496
- catch {
497
- /* best-effort */
498
- }
499
- await callback(finalContent, true, { assistantLogId });
500
- // Auto-continue: if the response was cut short by timeout, automatically
501
- // send a follow-up "Continue" message so the user doesn't have to
502
- if (finalContent.includes("⏱ Response was cut short (timeout)") && _autoContinueCount < MAX_AUTO_CONTINUE) {
503
- console.log(`[nzb] Auto-continuing after timeout (${_autoContinueCount + 1}/${MAX_AUTO_CONTINUE})…`);
504
- await sleep(1000);
505
- void sendToOrchestrator("Continue from where you left off. Do not repeat what was already said.", source, callback, onToolEvent, onUsage, _autoContinueCount + 1);
506
- }
507
- return;
508
- }
509
- catch (err) {
510
- const msg = err instanceof Error ? err.message : String(err);
511
- // Don't retry cancelled messages
512
- if (/cancelled|abort/i.test(msg)) {
513
- return;
514
- }
515
- if (isRecoverableError(err) && attempt < MAX_RETRIES) {
516
- const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
517
- console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
518
- await sleep(delay);
519
- // Reset client before retry in case the connection is stale
611
+ catch {
612
+ /* best-effort */
613
+ }
614
+ // Log both sides of the conversation before delivery so we have the row ID
615
+ let assistantLogId;
520
616
  try {
521
- await ensureClient();
617
+ const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
618
+ logConversation(logRole, prompt, sourceLabel, telegramMsgId);
522
619
  }
523
620
  catch {
524
- /* will fail again on next attempt */
621
+ /* best-effort */
622
+ }
623
+ try {
624
+ assistantLogId = logConversation("assistant", finalContent, sourceLabel);
625
+ }
626
+ catch {
627
+ /* best-effort */
628
+ }
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);
525
636
  }
526
- continue;
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;
527
689
  }
528
- console.error(`[nzb] Error processing message: ${msg}`);
529
- await callback(`Error: ${msg}`, true);
530
- return;
531
690
  }
532
691
  }
533
- })();
692
+ finally {
693
+ clearTimeout(globalTimer);
694
+ }
695
+ })().catch((err) => {
696
+ console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
697
+ });
534
698
  }
535
- /** Cancel the in-flight message and drain the queue. */
536
699
  export async function cancelCurrentMessage() {
537
700
  // Drain any queued messages
538
701
  const drained = messageQueue.length;
@@ -583,6 +746,7 @@ export async function resetSession() {
583
746
  }
584
747
  catch { }
585
748
  orchestratorSession = undefined;
749
+ sessionCreatedAt = undefined;
586
750
  }
587
751
  // Clear persisted session ID so a fresh session is created
588
752
  deleteState(ORCHESTRATOR_SESSION_KEY);
@@ -41,7 +41,8 @@ export function listSkills() {
41
41
  try {
42
42
  entries = readdirSync(dir);
43
43
  }
44
- catch {
44
+ catch (err) {
45
+ console.error("[nzb] Failed to read skill directory", dir + ":", err instanceof Error ? err.message : err);
45
46
  continue;
46
47
  }
47
48
  for (const entry of entries) {
@@ -60,7 +61,8 @@ export function listSkills() {
60
61
  source,
61
62
  });
62
63
  }
63
- catch {
64
+ catch (err) {
65
+ console.error("[nzb] Failed to parse SKILL.md for", entry + ":", err instanceof Error ? err.message : err);
64
66
  skills.push({
65
67
  slug: entry,
66
68
  name: entry,