@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.
- package/dist/api/server.js +14 -5
- package/dist/cli.js +1 -0
- package/dist/config.js +12 -3
- package/dist/copilot/client.js +17 -16
- package/dist/copilot/mcp-config.js +2 -0
- package/dist/copilot/orchestrator.js +181 -51
- package/dist/copilot/skills.js +4 -2
- package/dist/copilot/tools.js +33 -5
- package/dist/copilot/types.js +2 -0
- package/dist/daemon.js +11 -10
- package/dist/setup.js +3 -2
- package/dist/store/conversation.js +96 -0
- package/dist/store/db.js +6 -206
- package/dist/store/memory.js +90 -0
- package/dist/store/team-store.js +51 -0
- package/dist/telegram/bot.js +77 -8
- package/dist/telegram/handlers/commands.js +1 -1
- package/dist/telegram/handlers/media.js +63 -6
- package/dist/telegram/handlers/streaming.js +223 -188
- package/dist/telegram/handlers/suggestions.js +22 -1
- package/dist/telegram/log-channel.js +2 -2
- package/dist/telegram/menus.js +243 -99
- package/dist/tui/ansi.js +19 -0
- package/dist/tui/api-client.js +158 -0
- package/dist/tui/debug.js +27 -0
- package/dist/tui/renderer.js +59 -0
- package/dist/tui/stream.js +163 -0
- package/dist/update.js +2 -0
- package/dist/utils.js +102 -0
- package/package.json +1 -1
package/dist/api/server.js
CHANGED
|
@@ -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/
|
|
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 /
|
|
29
|
+
// Bearer token authentication middleware (skip /ping health check only)
|
|
30
30
|
app.use((req, res, next) => {
|
|
31
|
-
if (!apiToken || req.path === "/
|
|
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
|
-
//
|
|
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
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/copilot/client.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
return await getClient();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
pendingResetPromise = undefined;
|
|
27
34
|
}
|
|
28
|
-
return getClient();
|
|
29
35
|
})();
|
|
30
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
args?.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
accumulated
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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);
|
package/dist/copilot/skills.js
CHANGED
|
@@ -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,
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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();
|