@iletai/nzb 1.7.0 → 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
@@ -38,6 +38,14 @@ if (!Number.isInteger(parsedWorkerTimeout) || parsedWorkerTimeout <= 0) {
38
38
  }
39
39
  const parsedLogChannelId = raw.LOG_CHANNEL_ID ? raw.LOG_CHANNEL_ID.trim() : undefined;
40
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
+ }
41
49
  let _copilotModel = raw.COPILOT_MODEL || DEFAULT_MODEL;
42
50
  export const config = {
43
51
  telegramBotToken: raw.TELEGRAM_BOT_TOKEN,
@@ -65,15 +73,15 @@ export const config = {
65
73
  process.env.SHOW_REASONING = value ? "true" : "false";
66
74
  },
67
75
  /** Usage display mode: off | tokens | full */
68
- usageMode: (process.env.USAGE_MODE || "off"),
76
+ usageMode: validateEnum(process.env.USAGE_MODE, ["off", "tokens", "full"], "off", "USAGE_MODE"),
69
77
  /** Verbose mode: when on, instructs the AI to be more detailed */
70
78
  verboseMode: process.env.VERBOSE_MODE === "true",
71
79
  /** Thinking level: off | low | medium | high */
72
- thinkingLevel: (process.env.THINKING_LEVEL || "off"),
80
+ thinkingLevel: validateEnum(process.env.THINKING_LEVEL, ["off", "low", "medium", "high"], "off", "THINKING_LEVEL"),
73
81
  /** Group chat: when true, bot only responds when mentioned in groups */
74
82
  groupMentionOnly: process.env.GROUP_MENTION_ONLY !== "false",
75
83
  /** Reasoning effort: low | medium | high */
76
- reasoningEffort: (process.env.REASONING_EFFORT || "medium"),
84
+ reasoningEffort: validateEnum(process.env.REASONING_EFFORT, ["low", "medium", "high"], "medium", "REASONING_EFFORT"),
77
85
  };
78
86
  /** Persist an env variable to ~/.nzb/.env */
79
87
  export function persistEnvVar(key, value) {
@@ -94,6 +102,7 @@ export function persistEnvVar(key, value) {
94
102
  writeFileSync(ENV_PATH, updated.join("\n"));
95
103
  }
96
104
  catch {
105
+ // Expected: .env file may not exist yet on first run
97
106
  writeFileSync(ENV_PATH, `${key}=${value}\n`);
98
107
  }
99
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;
@@ -7,7 +8,7 @@ export async function getClient() {
7
8
  client = new CopilotClient({
8
9
  autoStart: true,
9
10
  });
10
- await client.start();
11
+ await withTimeout(client.start(), 30_000, "client.start()");
11
12
  }
12
13
  return client;
13
14
  }
@@ -16,27 +17,27 @@ export async function resetClient() {
16
17
  if (pendingResetPromise)
17
18
  return pendingResetPromise;
18
19
  pendingResetPromise = (async () => {
19
- if (client) {
20
- try {
21
- 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;
22
29
  }
23
- catch {
24
- /* best-effort */
25
- }
26
- client = undefined;
30
+ return await getClient();
31
+ }
32
+ finally {
33
+ pendingResetPromise = undefined;
27
34
  }
28
- return getClient();
29
35
  })();
30
- try {
31
- return await pendingResetPromise;
32
- }
33
- finally {
34
- pendingResetPromise = undefined;
35
- }
36
+ return pendingResetPromise;
36
37
  }
37
38
  export async function stopClient() {
38
39
  if (client) {
39
- await client.stop();
40
+ await withTimeout(client.stop(), 10_000, "client.stop()");
40
41
  client = undefined;
41
42
  }
42
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 {
@@ -250,7 +313,7 @@ async function createOrResumeSession() {
250
313
  // Recover conversation context if available (session was lost, not first run)
251
314
  // Runs concurrently but is awaited before any real message is sent on the session
252
315
  const recentHistory = getRecentConversation(10);
253
- if (recentHistory) {
316
+ if (!recoveryInjectionPromise && recentHistory) {
254
317
  console.log(`[nzb] Injecting recent conversation context into new session`);
255
318
  recoveryInjectionPromise = session
256
319
  .sendAndWait({
@@ -290,6 +353,7 @@ export async function initOrchestrator(client) {
290
353
  console.log(`[nzb] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
291
354
  console.log(`[nzb] Persistent session mode — conversation history maintained by SDK`);
292
355
  startHealthCheck();
356
+ startWorkerReaper();
293
357
  // Eagerly create/resume the orchestrator session
294
358
  try {
295
359
  await ensureOrchestratorSession();
@@ -303,53 +367,85 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
303
367
  const session = await ensureOrchestratorSession();
304
368
  // Wait for any in-flight context recovery injection to finish before sending
305
369
  if (recoveryInjectionPromise) {
306
- console.log(`[nzb] Waiting for context recovery injection to complete before sending…`);
307
- 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
+ });
308
374
  }
309
375
  currentCallback = callback;
310
376
  let accumulated = "";
311
377
  let toolCallExecuted = false;
312
378
  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 });
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
+ }
322
393
  });
323
394
  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 });
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
+ }
327
403
  });
328
404
  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);
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
+ }
337
418
  });
338
419
  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}`);
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
+ }
341
427
  });
342
428
  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 });
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
+ }
346
437
  });
347
438
  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 });
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
+ }
353
449
  });
354
450
  try {
355
451
  const sendPayload = { prompt };
@@ -364,6 +460,16 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
364
460
  }
365
461
  catch (err) {
366
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
+ }
367
473
  // On timeout, deliver whatever was accumulated instead of retrying from scratch
368
474
  if (/timeout/i.test(msg) && accumulated.length > 0) {
369
475
  console.log(`[nzb] Timeout — delivering ${accumulated.length} chars of partial content`);
@@ -373,6 +479,7 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachme
373
479
  if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
374
480
  console.log(`[nzb] Session appears dead, will recreate: ${msg}`);
375
481
  orchestratorSession = undefined;
482
+ sessionCreatedAt = undefined;
376
483
  deleteState(ORCHESTRATOR_SESSION_KEY);
377
484
  }
378
485
  throw err;
@@ -396,19 +503,27 @@ async function processQueue() {
396
503
  return;
397
504
  }
398
505
  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);
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;
408
518
  }
409
- currentSourceChannel = undefined;
410
519
  }
411
- 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
+ }
412
527
  }
413
528
  function isRecoverableError(err) {
414
529
  const msg = err instanceof Error ? err.message : String(err);
@@ -512,6 +627,19 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
512
627
  if (/cancelled|abort/i.test(msg)) {
513
628
  return;
514
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
+ }
515
643
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
516
644
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
517
645
  console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
@@ -530,9 +658,10 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
530
658
  return;
531
659
  }
532
660
  }
533
- })();
661
+ })().catch((err) => {
662
+ console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
663
+ });
534
664
  }
535
- /** Cancel the in-flight message and drain the queue. */
536
665
  export async function cancelCurrentMessage() {
537
666
  // Drain any queued messages
538
667
  const drained = messageQueue.length;
@@ -583,6 +712,7 @@ export async function resetSession() {
583
712
  }
584
713
  catch { }
585
714
  orchestratorSession = undefined;
715
+ sessionCreatedAt = undefined;
586
716
  }
587
717
  // Clear persisted session ID so a fresh session is created
588
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,
@@ -5,8 +5,8 @@ import { join, resolve, sep } from "path";
5
5
  import { z } from "zod";
6
6
  import { config, persistModel } from "../config.js";
7
7
  import { SESSIONS_DIR } from "../paths.js";
8
- import { addMemory, getDb, removeMemory, searchMemories } from "../store/db.js";
9
- import { getCurrentSourceChannel } from "./orchestrator.js";
8
+ import { getDb } from "../store/db.js";
9
+ import { addMemory, removeMemory, searchMemories } from "../store/memory.js";
10
10
  import { createSkill, listSkills, removeSkill } from "./skills.js";
11
11
  function isTimeoutError(err) {
12
12
  const msg = err instanceof Error ? err.message : String(err);
@@ -79,7 +79,7 @@ export function createTools(deps) {
79
79
  session,
80
80
  workingDir: args.working_dir,
81
81
  status: "idle",
82
- originChannel: getCurrentSourceChannel(),
82
+ originChannel: deps.getCurrentSourceChannel(),
83
83
  };
84
84
  deps.workers.set(args.name, worker);
85
85
  deps.onWorkerEvent?.({ type: "created", name: args.name, workingDir: args.working_dir });
@@ -221,6 +221,34 @@ export function createTools(deps) {
221
221
  return `Worker '${args.name}' terminated.`;
222
222
  },
223
223
  }),
224
+ defineTool("kill_worker", {
225
+ description: "Force-kill a stuck or unresponsive worker session by name. " +
226
+ "Use when a worker is hanging or no longer needed.",
227
+ parameters: z.object({
228
+ name: z.string().describe("Name of the worker to kill"),
229
+ }),
230
+ handler: async (args) => {
231
+ const worker = deps.workers.get(args.name);
232
+ if (!worker)
233
+ return `No worker found with name '${args.name}'.`;
234
+ try {
235
+ worker.session.disconnect().catch(() => { });
236
+ }
237
+ catch {
238
+ // Session may already be destroyed
239
+ }
240
+ deps.workers.delete(args.name);
241
+ try {
242
+ getDb()
243
+ .prepare(`DELETE FROM worker_sessions WHERE name = ?`)
244
+ .run(args.name);
245
+ }
246
+ catch {
247
+ // DB cleanup is best-effort
248
+ }
249
+ return `Worker '${args.name}' force-killed.`;
250
+ },
251
+ }),
224
252
  // ── Agent Team Tools ──────────────────────────────────────────
225
253
  defineTool("create_agent_team", {
226
254
  description: "Create an agent team — multiple workers collaborating on a task in parallel. Each member gets a role " +
@@ -264,7 +292,7 @@ export function createTools(deps) {
264
292
  }
265
293
  }
266
294
  const teamId = args.team_name;
267
- const originChannel = getCurrentSourceChannel();
295
+ const originChannel = deps.getCurrentSourceChannel();
268
296
  const { createTeam: dbCreateTeam, addTeamMember: dbAddTeamMember } = await import("../store/db.js");
269
297
  dbCreateTeam(teamId, args.task_description, originChannel);
270
298
  const teamInfo = {
@@ -488,7 +516,7 @@ export function createTools(deps) {
488
516
  session,
489
517
  workingDir: "(attached)",
490
518
  status: "idle",
491
- originChannel: getCurrentSourceChannel(),
519
+ originChannel: deps.getCurrentSourceChannel(),
492
520
  };
493
521
  deps.workers.set(args.name, worker);
494
522
  const db = getDb();
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map