@iletai/nzb 1.6.4 → 1.7.3

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.
@@ -6,7 +6,7 @@ import { cancelCurrentMessage, getWorkers, sendToOrchestrator } from "../copilot
6
6
  import { listSkills, removeSkill } from "../copilot/skills.js";
7
7
  import { restartDaemon } from "../daemon.js";
8
8
  import { API_TOKEN_PATH, ensureNZBHome } from "../paths.js";
9
- import { searchMemories } from "../store/db.js";
9
+ import { searchMemories } from "../store/memory.js";
10
10
  import { sendPhoto } from "../telegram/bot.js";
11
11
  // Ensure token file exists (generate on first run)
12
12
  let apiToken = null;
@@ -26,9 +26,9 @@ catch (err) {
26
26
  }
27
27
  const app = express();
28
28
  app.use(express.json());
29
- // Bearer token authentication middleware (skip /status health check)
29
+ // Bearer token authentication middleware (skip /ping health check only)
30
30
  app.use((req, res, next) => {
31
- if (!apiToken || req.path === "/status")
31
+ if (!apiToken || req.path === "/ping")
32
32
  return next();
33
33
  const auth = req.headers.authorization;
34
34
  if (!auth || auth !== `Bearer ${apiToken}`) {
@@ -40,7 +40,11 @@ app.use((req, res, next) => {
40
40
  // Active SSE connections
41
41
  const sseClients = new Map();
42
42
  let connectionCounter = 0;
43
- // Health check
43
+ // Minimal unauthenticated health check — no internal details
44
+ app.get("/ping", (_req, res) => {
45
+ res.json({ status: "ok" });
46
+ });
47
+ // Authenticated status with worker details
44
48
  app.get("/status", (_req, res) => {
45
49
  res.json({
46
50
  status: "ok",
@@ -175,7 +179,12 @@ app.post("/restart", (_req, res) => {
175
179
  app.post("/send-photo", async (req, res) => {
176
180
  const { photo, caption } = req.body;
177
181
  if (!photo || typeof photo !== "string") {
178
- res.status(400).json({ error: "Missing 'photo' (file path or URL) in request body" });
182
+ res.status(400).json({ error: "Missing 'photo' (file path or HTTPS URL) in request body" });
183
+ return;
184
+ }
185
+ // Basic input validation before passing to sendPhoto
186
+ if (photo.startsWith("http://")) {
187
+ res.status(400).json({ error: "Only HTTPS URLs are allowed for photos" });
179
188
  return;
180
189
  }
181
190
  try {
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ function getVersion() {
28
28
  return pkg.version || "0.0.0";
29
29
  }
30
30
  catch {
31
+ // Expected: package.json may not be found in dev/bundled environments
31
32
  return "0.0.0";
32
33
  }
33
34
  }
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ const configSchema = z.object({
15
15
  LOG_CHANNEL_ID: z.string().optional(),
16
16
  NODE_EXTRA_CA_CERTS: z.string().optional(),
17
17
  OPENAI_API_KEY: z.string().optional(),
18
+ REASONING_EFFORT: z.string().optional(),
18
19
  });
19
20
  const raw = configSchema.parse(process.env);
20
21
  // Apply NODE_EXTRA_CA_CERTS from .env if not already set via environment.
@@ -37,6 +38,14 @@ if (!Number.isInteger(parsedWorkerTimeout) || parsedWorkerTimeout <= 0) {
37
38
  }
38
39
  const parsedLogChannelId = raw.LOG_CHANNEL_ID ? raw.LOG_CHANNEL_ID.trim() : undefined;
39
40
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
41
+ function validateEnum(value, validValues, defaultValue, name) {
42
+ if (!value)
43
+ return defaultValue;
44
+ if (validValues.includes(value))
45
+ return value;
46
+ console.log(`[nzb] Invalid ${name} value "${value}", using default "${defaultValue}"`);
47
+ return defaultValue;
48
+ }
40
49
  let _copilotModel = raw.COPILOT_MODEL || DEFAULT_MODEL;
41
50
  export const config = {
42
51
  telegramBotToken: raw.TELEGRAM_BOT_TOKEN,
@@ -64,13 +73,15 @@ export const config = {
64
73
  process.env.SHOW_REASONING = value ? "true" : "false";
65
74
  },
66
75
  /** Usage display mode: off | tokens | full */
67
- usageMode: (process.env.USAGE_MODE || "off"),
76
+ usageMode: validateEnum(process.env.USAGE_MODE, ["off", "tokens", "full"], "off", "USAGE_MODE"),
68
77
  /** Verbose mode: when on, instructs the AI to be more detailed */
69
78
  verboseMode: process.env.VERBOSE_MODE === "true",
70
79
  /** Thinking level: off | low | medium | high */
71
- thinkingLevel: (process.env.THINKING_LEVEL || "off"),
80
+ thinkingLevel: validateEnum(process.env.THINKING_LEVEL, ["off", "low", "medium", "high"], "off", "THINKING_LEVEL"),
72
81
  /** Group chat: when true, bot only responds when mentioned in groups */
73
82
  groupMentionOnly: process.env.GROUP_MENTION_ONLY !== "false",
83
+ /** Reasoning effort: low | medium | high */
84
+ reasoningEffort: validateEnum(process.env.REASONING_EFFORT, ["low", "medium", "high"], "medium", "REASONING_EFFORT"),
74
85
  };
75
86
  /** Persist an env variable to ~/.nzb/.env */
76
87
  export function persistEnvVar(key, value) {
@@ -91,6 +102,7 @@ export function persistEnvVar(key, value) {
91
102
  writeFileSync(ENV_PATH, updated.join("\n"));
92
103
  }
93
104
  catch {
105
+ // Expected: .env file may not exist yet on first run
94
106
  writeFileSync(ENV_PATH, `${key}=${value}\n`);
95
107
  }
96
108
  }
@@ -1,4 +1,5 @@
1
1
  import { CopilotClient } from "@github/copilot-sdk";
2
+ import { withTimeout } from "../utils.js";
2
3
  let client;
3
4
  /** Coalesces concurrent resetClient() calls into a single reset operation. */
4
5
  let pendingResetPromise;
@@ -6,9 +7,8 @@ export async function getClient() {
6
7
  if (!client) {
7
8
  client = new CopilotClient({
8
9
  autoStart: true,
9
- autoRestart: true,
10
10
  });
11
- await client.start();
11
+ await withTimeout(client.start(), 30_000, "client.start()");
12
12
  }
13
13
  return client;
14
14
  }
@@ -17,27 +17,27 @@ export async function resetClient() {
17
17
  if (pendingResetPromise)
18
18
  return pendingResetPromise;
19
19
  pendingResetPromise = (async () => {
20
- if (client) {
21
- try {
22
- await client.stop();
20
+ try {
21
+ if (client) {
22
+ try {
23
+ await withTimeout(client.stop(), 10_000, "client.stop()");
24
+ }
25
+ catch (err) {
26
+ console.error("[nzb] Error stopping client during reset:", err);
27
+ }
28
+ client = undefined;
23
29
  }
24
- catch {
25
- /* best-effort */
26
- }
27
- client = undefined;
30
+ return await getClient();
31
+ }
32
+ finally {
33
+ pendingResetPromise = undefined;
28
34
  }
29
- return getClient();
30
35
  })();
31
- try {
32
- return await pendingResetPromise;
33
- }
34
- finally {
35
- pendingResetPromise = undefined;
36
- }
36
+ return pendingResetPromise;
37
37
  }
38
38
  export async function stopClient() {
39
39
  if (client) {
40
- await client.stop();
40
+ await withTimeout(client.stop(), 10_000, "client.stop()");
41
41
  client = undefined;
42
42
  }
43
43
  }
@@ -10,6 +10,7 @@ const isWSL = (() => {
10
10
  return readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft");
11
11
  }
12
12
  catch {
13
+ // Expected: /proc/version may not exist on non-Linux systems
13
14
  return false;
14
15
  }
15
16
  })();
@@ -86,6 +87,7 @@ export function loadMcpConfig() {
86
87
  return cachedConfig;
87
88
  }
88
89
  catch {
90
+ // Expected: config file may not exist or be malformed
89
91
  cachedConfig = {};
90
92
  return cachedConfig;
91
93
  }
@@ -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,35 @@ 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
+ healthCheckRunning = true;
147
159
  try {
148
160
  const state = copilotClient.getState();
149
161
  if (state !== "connected") {
150
162
  console.log(`[nzb] Health check: client state is '${state}', resetting…`);
151
163
  const previousClient = copilotClient;
152
- await ensureClient();
164
+ await withTimeout(ensureClient(), 15_000, "health check");
153
165
  // Only invalidate session if the underlying client actually changed
154
166
  if (copilotClient !== previousClient) {
155
167
  orchestratorSession = undefined;
168
+ sessionCreatedAt = undefined;
156
169
  }
157
170
  }
158
171
  }
159
172
  catch (err) {
160
173
  console.error(`[nzb] Health check error:`, err instanceof Error ? err.message : err);
161
174
  }
175
+ finally {
176
+ healthCheckRunning = false;
177
+ }
162
178
  }, HEALTH_CHECK_INTERVAL_MS);
163
179
  }
164
180
  /** Stop the periodic health check timer. Call during shutdown. */
@@ -168,17 +184,64 @@ export function stopHealthCheck() {
168
184
  healthCheckTimer = undefined;
169
185
  }
170
186
  }
187
+ /** Periodically kills workers that have exceeded 2× their configured timeout. */
188
+ function startWorkerReaper() {
189
+ if (workerReaperTimer)
190
+ return;
191
+ workerReaperTimer = setInterval(() => {
192
+ const maxAge = config.workerTimeoutMs * 2;
193
+ const now = Date.now();
194
+ for (const [name, worker] of workers) {
195
+ if (worker.startedAt && now - worker.startedAt > maxAge) {
196
+ console.log(`[nzb] Reaping stuck worker '${name}' (age: ${formatAge(worker.startedAt)})`);
197
+ try {
198
+ worker.session.disconnect().catch(() => { });
199
+ }
200
+ catch {
201
+ // Session may already be destroyed
202
+ }
203
+ workers.delete(name);
204
+ feedBackgroundResult(name, `⚠ Worker '${name}' was automatically killed after exceeding timeout.`);
205
+ }
206
+ }
207
+ }, 5 * 60 * 1000);
208
+ workerReaperTimer.unref();
209
+ }
210
+ const SESSION_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
171
211
  /** Create or resume the persistent orchestrator session. */
172
212
  async function ensureOrchestratorSession() {
173
- if (orchestratorSession)
174
- return orchestratorSession;
213
+ if (orchestratorSession) {
214
+ // Validate session is still usable — check client connectivity
215
+ try {
216
+ const clientState = copilotClient?.getState?.();
217
+ if (clientState && clientState !== "connected") {
218
+ console.log(`[nzb] Session stale (client state: ${clientState}), recreating…`);
219
+ orchestratorSession = undefined;
220
+ sessionCreatedAt = undefined;
221
+ }
222
+ }
223
+ catch {
224
+ console.log("[nzb] Session validation failed, recreating…");
225
+ orchestratorSession = undefined;
226
+ sessionCreatedAt = undefined;
227
+ }
228
+ // Enforce session TTL
229
+ if (sessionCreatedAt && Date.now() - sessionCreatedAt > SESSION_MAX_AGE_MS) {
230
+ console.log("[nzb] Session TTL expired, recreating…");
231
+ orchestratorSession = undefined;
232
+ sessionCreatedAt = undefined;
233
+ }
234
+ if (orchestratorSession)
235
+ return orchestratorSession;
236
+ }
175
237
  // Coalesce concurrent callers — wait for an in-flight creation
176
238
  if (sessionCreatePromise)
177
239
  return sessionCreatePromise;
178
- sessionCreatePromise = createOrResumeSession();
240
+ sessionCreatePromise = withTimeout(createOrResumeSession(), 30_000, "session create/resume");
179
241
  try {
180
242
  const session = await sessionCreatePromise;
181
243
  orchestratorSession = session;
244
+ sessionCreatedAt = Date.now();
182
245
  return session;
183
246
  }
184
247
  finally {
@@ -204,6 +267,7 @@ async function createOrResumeSession() {
204
267
  model: config.copilotModel,
205
268
  configDir: SESSIONS_DIR,
206
269
  streaming: true,
270
+ reasoningEffort: config.reasoningEffort,
207
271
  systemMessage: {
208
272
  content: getOrchestratorSystemMessage(memorySummary || undefined, {
209
273
  selfEditEnabled: config.selfEditEnabled,
@@ -230,6 +294,7 @@ async function createOrResumeSession() {
230
294
  model: config.copilotModel,
231
295
  configDir: SESSIONS_DIR,
232
296
  streaming: true,
297
+ reasoningEffort: config.reasoningEffort,
233
298
  systemMessage: {
234
299
  content: getOrchestratorSystemMessage(memorySummary || undefined, {
235
300
  selfEditEnabled: config.selfEditEnabled,
@@ -248,7 +313,7 @@ async function createOrResumeSession() {
248
313
  // Recover conversation context if available (session was lost, not first run)
249
314
  // Runs concurrently but is awaited before any real message is sent on the session
250
315
  const recentHistory = getRecentConversation(10);
251
- if (recentHistory) {
316
+ if (!recoveryInjectionPromise && recentHistory) {
252
317
  console.log(`[nzb] Injecting recent conversation context into new session`);
253
318
  recoveryInjectionPromise = session
254
319
  .sendAndWait({
@@ -288,6 +353,7 @@ export async function initOrchestrator(client) {
288
353
  console.log(`[nzb] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
289
354
  console.log(`[nzb] Persistent session mode — conversation history maintained by SDK`);
290
355
  startHealthCheck();
356
+ startWorkerReaper();
291
357
  // Eagerly create/resume the orchestrator session
292
358
  try {
293
359
  await ensureOrchestratorSession();
@@ -297,60 +363,96 @@ export async function initOrchestrator(client) {
297
363
  }
298
364
  }
299
365
  /** Send a prompt on the persistent session, return the response. */
300
- async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
366
+ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachments) {
301
367
  const session = await ensureOrchestratorSession();
302
368
  // Wait for any in-flight context recovery injection to finish before sending
303
369
  if (recoveryInjectionPromise) {
304
- console.log(`[nzb] Waiting for context recovery injection to complete before sending…`);
305
- await recoveryInjectionPromise;
370
+ console.log("[nzb] Waiting for context recovery…");
371
+ await withTimeout(recoveryInjectionPromise, 25_000, "recovery injection wait").catch(() => {
372
+ console.log("[nzb] Recovery injection wait timed out, proceeding anyway");
373
+ });
306
374
  }
307
375
  currentCallback = callback;
308
376
  let accumulated = "";
309
377
  let toolCallExecuted = false;
310
378
  const unsubToolStart = session.on("tool.execution_start", (event) => {
311
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
312
- const args = event?.data?.arguments;
313
- const detail = args?.description ||
314
- args?.command?.slice(0, 80) ||
315
- args?.intent ||
316
- args?.pattern ||
317
- args?.prompt?.slice(0, 80) ||
318
- undefined;
319
- onToolEvent?.({ type: "tool_start", toolName, detail });
379
+ try {
380
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
381
+ const args = event?.data?.arguments;
382
+ const detail = args?.description ||
383
+ args?.command?.slice(0, 80) ||
384
+ args?.intent ||
385
+ args?.pattern ||
386
+ args?.prompt?.slice(0, 80) ||
387
+ undefined;
388
+ onToolEvent?.({ type: "tool_start", toolName, detail });
389
+ }
390
+ catch (err) {
391
+ console.error("[nzb] Error in tool.execution_start listener:", err instanceof Error ? err.message : err);
392
+ }
320
393
  });
321
394
  const unsubToolDone = session.on("tool.execution_complete", (event) => {
322
- toolCallExecuted = true;
323
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
324
- onToolEvent?.({ type: "tool_complete", toolName });
395
+ try {
396
+ toolCallExecuted = true;
397
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
398
+ onToolEvent?.({ type: "tool_complete", toolName });
399
+ }
400
+ catch (err) {
401
+ console.error("[nzb] Error in tool.execution_complete listener:", err instanceof Error ? err.message : err);
402
+ }
325
403
  });
326
404
  const unsubDelta = session.on("assistant.message_delta", (event) => {
327
- // After a tool call completes, ensure a line break separates the text blocks
328
- // so they don't visually run together in the TUI.
329
- if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
330
- accumulated += "\n";
331
- }
332
- toolCallExecuted = false;
333
- accumulated += event.data.deltaContent;
334
- callback(accumulated, false);
405
+ try {
406
+ // After a tool call completes, ensure a line break separates the text blocks
407
+ // so they don't visually run together in the TUI.
408
+ if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
409
+ accumulated += "\n";
410
+ }
411
+ toolCallExecuted = false;
412
+ accumulated += event.data.deltaContent;
413
+ callback(accumulated, false);
414
+ }
415
+ catch (err) {
416
+ console.error("[nzb] Error in message_delta listener:", err instanceof Error ? err.message : err);
417
+ }
335
418
  });
336
419
  const unsubError = session.on("session.error", (event) => {
337
- const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
338
- console.error(`[nzb] Session error event: ${errMsg}`);
420
+ try {
421
+ const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
422
+ console.error(`[nzb] Session error event: ${errMsg}`);
423
+ }
424
+ catch (err) {
425
+ console.error("[nzb] Error in session.error listener:", err instanceof Error ? err.message : err);
426
+ }
339
427
  });
340
428
  const unsubPartialResult = session.on("tool.execution_partial_result", (event) => {
341
- const toolName = event?.data?.toolName || event?.data?.name || "tool";
342
- const partialOutput = event?.data?.partialOutput || "";
343
- onToolEvent?.({ type: "tool_partial_result", toolName, detail: partialOutput });
429
+ try {
430
+ const toolName = event?.data?.toolName || event?.data?.name || "tool";
431
+ const partialOutput = event?.data?.partialOutput || "";
432
+ onToolEvent?.({ type: "tool_partial_result", toolName, detail: partialOutput });
433
+ }
434
+ catch (err) {
435
+ console.error("[nzb] Error in tool.execution_partial_result listener:", err instanceof Error ? err.message : err);
436
+ }
344
437
  });
345
438
  const unsubUsage = session.on("assistant.usage", (event) => {
346
- const inputTokens = event?.data?.inputTokens || 0;
347
- const outputTokens = event?.data?.outputTokens || 0;
348
- const model = event?.data?.model || undefined;
349
- const duration = event?.data?.duration || undefined;
350
- onUsage?.({ inputTokens, outputTokens, model, duration });
439
+ try {
440
+ const inputTokens = event?.data?.inputTokens || 0;
441
+ const outputTokens = event?.data?.outputTokens || 0;
442
+ const model = event?.data?.model || undefined;
443
+ const duration = event?.data?.duration || undefined;
444
+ onUsage?.({ inputTokens, outputTokens, model, duration });
445
+ }
446
+ catch (err) {
447
+ console.error("[nzb] Error in assistant.usage listener:", err instanceof Error ? err.message : err);
448
+ }
351
449
  });
352
450
  try {
353
- const result = await session.sendAndWait({ prompt }, 60_000);
451
+ const sendPayload = { prompt };
452
+ if (attachments?.length) {
453
+ sendPayload.attachments = attachments;
454
+ }
455
+ const result = await session.sendAndWait(sendPayload, 60_000);
354
456
  // Allow late-arriving events (e.g. assistant.usage) to be processed
355
457
  await new Promise((r) => setTimeout(r, 150));
356
458
  const finalContent = result?.data?.content || accumulated || "(No response)";
@@ -358,6 +460,16 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
358
460
  }
359
461
  catch (err) {
360
462
  const msg = err instanceof Error ? err.message : String(err);
463
+ // Vision not supported — the session is now tainted with image data in its history,
464
+ // so ALL subsequent messages would fail. Force-recreate the session to recover.
465
+ // The retry (with stripped attachments) is handled by sendToOrchestrator's retry loop.
466
+ if (/not supported for vision/i.test(msg)) {
467
+ console.log(`[nzb] Model '${config.copilotModel}' does not support vision — destroying tainted session`);
468
+ orchestratorSession = undefined;
469
+ sessionCreatedAt = undefined;
470
+ deleteState(ORCHESTRATOR_SESSION_KEY);
471
+ throw err;
472
+ }
361
473
  // On timeout, deliver whatever was accumulated instead of retrying from scratch
362
474
  if (/timeout/i.test(msg) && accumulated.length > 0) {
363
475
  console.log(`[nzb] Timeout — delivering ${accumulated.length} chars of partial content`);
@@ -367,6 +479,7 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
367
479
  if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
368
480
  console.log(`[nzb] Session appears dead, will recreate: ${msg}`);
369
481
  orchestratorSession = undefined;
482
+ sessionCreatedAt = undefined;
370
483
  deleteState(ORCHESTRATOR_SESSION_KEY);
371
484
  }
372
485
  throw err;
@@ -390,26 +503,34 @@ async function processQueue() {
390
503
  return;
391
504
  }
392
505
  processing = true;
393
- while (messageQueue.length > 0) {
394
- const item = messageQueue.shift();
395
- currentSourceChannel = item.sourceChannel;
396
- try {
397
- const result = await executeOnSession(item.prompt, item.callback, item.onToolEvent, item.onUsage);
398
- item.resolve(result);
399
- }
400
- catch (err) {
401
- item.reject(err);
506
+ try {
507
+ while (messageQueue.length > 0) {
508
+ const item = messageQueue.shift();
509
+ currentSourceChannel = item.sourceChannel;
510
+ try {
511
+ const result = await executeOnSession(item.prompt, item.callback, item.onToolEvent, item.onUsage, item.attachments);
512
+ item.resolve(result);
513
+ }
514
+ catch (err) {
515
+ item.reject(err);
516
+ }
517
+ currentSourceChannel = undefined;
402
518
  }
403
- currentSourceChannel = undefined;
404
519
  }
405
- processing = false;
520
+ finally {
521
+ processing = false;
522
+ }
523
+ // Re-check for messages that arrived during the last executeOnSession call
524
+ if (messageQueue.length > 0) {
525
+ void processQueue();
526
+ }
406
527
  }
407
528
  function isRecoverableError(err) {
408
529
  const msg = err instanceof Error ? err.message : String(err);
409
530
  return /timeout|disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
410
531
  }
411
532
  const MAX_AUTO_CONTINUE = 3;
412
- export async function sendToOrchestrator(prompt, source, callback, onToolEvent, onUsage, _autoContinueCount = 0) {
533
+ export async function sendToOrchestrator(prompt, source, callback, onToolEvent, onUsage, _autoContinueCount = 0, attachments) {
413
534
  const sourceLabel = source.type === "telegram" ? "telegram" : source.type === "tui" ? "tui" : "background";
414
535
  logMessage("in", sourceLabel, prompt);
415
536
  // Tag the prompt with its source channel
@@ -444,6 +565,7 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
444
565
  const finalContent = await new Promise((resolve, reject) => {
445
566
  const item = {
446
567
  prompt: taggedPrompt,
568
+ attachments,
447
569
  callback,
448
570
  onToolEvent,
449
571
  onUsage,
@@ -505,6 +627,19 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
505
627
  if (/cancelled|abort/i.test(msg)) {
506
628
  return;
507
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
+ }
508
643
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
509
644
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
510
645
  console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
@@ -523,9 +658,10 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
523
658
  return;
524
659
  }
525
660
  }
526
- })();
661
+ })().catch((err) => {
662
+ console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
663
+ });
527
664
  }
528
- /** Cancel the in-flight message and drain the queue. */
529
665
  export async function cancelCurrentMessage() {
530
666
  // Drain any queued messages
531
667
  const drained = messageQueue.length;
@@ -572,10 +708,11 @@ export async function resetSession() {
572
708
  // Destroy the existing session
573
709
  if (orchestratorSession) {
574
710
  try {
575
- await orchestratorSession.destroy();
711
+ await orchestratorSession.disconnect();
576
712
  }
577
713
  catch { }
578
714
  orchestratorSession = undefined;
715
+ sessionCreatedAt = undefined;
579
716
  }
580
717
  // Clear persisted session ID so a fresh session is created
581
718
  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,