ctb 1.2.1 → 1.3.0
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/README.md +8 -0
- package/docs/plans/2026-02-01-performance-reliability-improvements.md +1038 -0
- package/package.json +1 -1
- package/src/__tests__/errors.test.ts +60 -0
- package/src/__tests__/events.test.ts +43 -0
- package/src/__tests__/session.test.ts +73 -0
- package/src/__tests__/telegram-api.test.ts +80 -0
- package/src/errors.ts +58 -0
- package/src/events.ts +57 -0
- package/src/handlers/streaming.ts +23 -27
- package/src/handlers/text.ts +2 -1
- package/src/index.ts +34 -16
- package/src/session.ts +67 -1
- package/src/telegram-api.ts +195 -0
- package/src/types.ts +34 -33
- package/src/utils.ts +4 -21
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for error formatting.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { formatUserError } from "../errors";
|
|
7
|
+
|
|
8
|
+
describe("formatUserError", () => {
|
|
9
|
+
test("formats timeout error", () => {
|
|
10
|
+
const msg = formatUserError(new Error("Query timeout (180s > 180s limit)"));
|
|
11
|
+
expect(msg).toContain("took too long");
|
|
12
|
+
expect(msg).not.toContain("timeout");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("formats rate limit error", () => {
|
|
16
|
+
const msg = formatUserError(new Error("Too Many Requests: retry after 5"));
|
|
17
|
+
expect(msg).toContain("busy");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("formats network error", () => {
|
|
21
|
+
const msg = formatUserError(new Error("ETIMEDOUT"));
|
|
22
|
+
expect(msg.toLowerCase()).toContain("connection");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("formats generic error with truncation", () => {
|
|
26
|
+
const longError = "A".repeat(300);
|
|
27
|
+
const msg = formatUserError(new Error(longError));
|
|
28
|
+
expect(msg.length).toBeLessThan(250);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("formats cancelled/aborted error", () => {
|
|
32
|
+
const msg = formatUserError(new Error("Request was cancelled by user"));
|
|
33
|
+
expect(msg).toContain("cancelled");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("formats unsafe command error", () => {
|
|
37
|
+
const msg = formatUserError(new Error("unsafe command detected"));
|
|
38
|
+
expect(msg).toContain("safety");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("formats file access error", () => {
|
|
42
|
+
const msg = formatUserError(new Error("outside allowed paths"));
|
|
43
|
+
expect(msg).toContain("file location");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("formats authentication error", () => {
|
|
47
|
+
const msg = formatUserError(new Error("401 unauthorized"));
|
|
48
|
+
expect(msg).toContain("Authentication");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("formats ECONNRESET error", () => {
|
|
52
|
+
const msg = formatUserError(new Error("ECONNRESET"));
|
|
53
|
+
expect(msg).toContain("Connection");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("handles error with empty message", () => {
|
|
57
|
+
const msg = formatUserError(new Error(""));
|
|
58
|
+
expect(msg).toContain("Error");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for event emitter module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { botEvents } from "../events";
|
|
7
|
+
|
|
8
|
+
describe("BotEvents", () => {
|
|
9
|
+
test("emits and receives events", () => {
|
|
10
|
+
let received = false;
|
|
11
|
+
const unsubscribe = botEvents.on("sessionRunning", (running) => {
|
|
12
|
+
received = running;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
botEvents.emit("sessionRunning", true);
|
|
16
|
+
expect(received).toBe(true);
|
|
17
|
+
|
|
18
|
+
unsubscribe();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("unsubscribe stops receiving events", () => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
const unsubscribe = botEvents.on("sessionRunning", () => {
|
|
24
|
+
count++;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
botEvents.emit("sessionRunning", true);
|
|
28
|
+
expect(count).toBe(1);
|
|
29
|
+
|
|
30
|
+
unsubscribe();
|
|
31
|
+
|
|
32
|
+
botEvents.emit("sessionRunning", true);
|
|
33
|
+
expect(count).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("getSessionState returns current state", () => {
|
|
37
|
+
botEvents.emit("sessionRunning", false);
|
|
38
|
+
expect(botEvents.getSessionState()).toBe(false);
|
|
39
|
+
|
|
40
|
+
botEvents.emit("sessionRunning", true);
|
|
41
|
+
expect(botEvents.getSessionState()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -396,4 +396,77 @@ describe("ClaudeSession", () => {
|
|
|
396
396
|
expect(session.canUndo).toBe(false);
|
|
397
397
|
});
|
|
398
398
|
});
|
|
399
|
+
|
|
400
|
+
describe("session debouncing", () => {
|
|
401
|
+
test("multiple saveSession calls within debounce window only write once", async () => {
|
|
402
|
+
let writeCount = 0;
|
|
403
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
404
|
+
const originalWrite = (Bun as any).write;
|
|
405
|
+
|
|
406
|
+
// Mock Bun.write to count session.json writes
|
|
407
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
408
|
+
(Bun as any).write = async (...args: unknown[]) => {
|
|
409
|
+
if (String(args[0]).includes("session.json")) {
|
|
410
|
+
writeCount++;
|
|
411
|
+
}
|
|
412
|
+
return originalWrite.apply(Bun, args);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Set up a session ID to trigger saves
|
|
417
|
+
session.sessionId = "test-session-debounce-1";
|
|
418
|
+
|
|
419
|
+
// Call saveSession multiple times rapidly
|
|
420
|
+
// @ts-expect-error - accessing private for test
|
|
421
|
+
session.saveSession();
|
|
422
|
+
// @ts-expect-error - accessing private for test
|
|
423
|
+
session.saveSession();
|
|
424
|
+
// @ts-expect-error - accessing private for test
|
|
425
|
+
session.saveSession();
|
|
426
|
+
|
|
427
|
+
// Immediately after calls, write should not have happened yet (debounced)
|
|
428
|
+
expect(writeCount).toBe(0);
|
|
429
|
+
|
|
430
|
+
// Wait for debounce timeout (500ms + buffer)
|
|
431
|
+
await Bun.sleep(600);
|
|
432
|
+
|
|
433
|
+
// Now exactly one write should have occurred
|
|
434
|
+
expect(writeCount).toBe(1);
|
|
435
|
+
} finally {
|
|
436
|
+
// Restore original Bun.write
|
|
437
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
438
|
+
(Bun as any).write = originalWrite;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("flushSession writes immediately", async () => {
|
|
443
|
+
let writeCount = 0;
|
|
444
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
445
|
+
const originalWrite = (Bun as any).write;
|
|
446
|
+
|
|
447
|
+
// Mock Bun.write to count session.json writes
|
|
448
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
449
|
+
(Bun as any).write = async (...args: unknown[]) => {
|
|
450
|
+
if (String(args[0]).includes("session.json")) {
|
|
451
|
+
writeCount++;
|
|
452
|
+
}
|
|
453
|
+
return originalWrite.apply(Bun, args);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Set up a session ID to trigger saves
|
|
458
|
+
session.sessionId = "test-session-flush-1";
|
|
459
|
+
|
|
460
|
+
// Call flushSession for immediate write
|
|
461
|
+
session.flushSession();
|
|
462
|
+
|
|
463
|
+
// Write should happen immediately (synchronously)
|
|
464
|
+
expect(writeCount).toBe(1);
|
|
465
|
+
} finally {
|
|
466
|
+
// Restore original Bun.write
|
|
467
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
468
|
+
(Bun as any).write = originalWrite;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
});
|
|
399
472
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Telegram API utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { TelegramApiError, withRetry } from "../telegram-api";
|
|
7
|
+
|
|
8
|
+
describe("withRetry", () => {
|
|
9
|
+
test("succeeds on first attempt", async () => {
|
|
10
|
+
let attempts = 0;
|
|
11
|
+
const result = await withRetry(async () => {
|
|
12
|
+
attempts++;
|
|
13
|
+
return "success";
|
|
14
|
+
});
|
|
15
|
+
expect(result).toBe("success");
|
|
16
|
+
expect(attempts).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("retries on transient failure then succeeds", async () => {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
const result = await withRetry(
|
|
22
|
+
async () => {
|
|
23
|
+
attempts++;
|
|
24
|
+
if (attempts < 3) {
|
|
25
|
+
throw new Error("Too Many Requests: retry after 1");
|
|
26
|
+
}
|
|
27
|
+
return "success";
|
|
28
|
+
},
|
|
29
|
+
{ maxRetries: 3, baseDelay: 10 },
|
|
30
|
+
);
|
|
31
|
+
expect(result).toBe("success");
|
|
32
|
+
expect(attempts).toBe(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("throws after max retries", async () => {
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
await expect(
|
|
38
|
+
withRetry(
|
|
39
|
+
async () => {
|
|
40
|
+
attempts++;
|
|
41
|
+
throw new Error("Too Many Requests: retry after 1");
|
|
42
|
+
},
|
|
43
|
+
{ maxRetries: 2, baseDelay: 10 },
|
|
44
|
+
),
|
|
45
|
+
).rejects.toThrow();
|
|
46
|
+
expect(attempts).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not retry non-transient errors", async () => {
|
|
50
|
+
let attempts = 0;
|
|
51
|
+
await expect(
|
|
52
|
+
withRetry(
|
|
53
|
+
async () => {
|
|
54
|
+
attempts++;
|
|
55
|
+
throw new Error("Bad Request: message not found");
|
|
56
|
+
},
|
|
57
|
+
{ maxRetries: 3, baseDelay: 10 },
|
|
58
|
+
),
|
|
59
|
+
).rejects.toThrow("Bad Request");
|
|
60
|
+
expect(attempts).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("TelegramApiError", () => {
|
|
65
|
+
test("isTransient returns true for rate limit errors", () => {
|
|
66
|
+
const error = new TelegramApiError("Too Many Requests: retry after 5", 429);
|
|
67
|
+
expect(error.isTransient).toBe(true);
|
|
68
|
+
expect(error.retryAfter).toBe(5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("isTransient returns true for network errors", () => {
|
|
72
|
+
const error = new TelegramApiError("ETIMEDOUT", 0);
|
|
73
|
+
expect(error.isTransient).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("isTransient returns false for bad request", () => {
|
|
77
|
+
const error = new TelegramApiError("Bad Request: message not found", 400);
|
|
78
|
+
expect(error.isTransient).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-friendly error message formatting.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
interface ErrorPattern {
|
|
6
|
+
pattern: RegExp;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ERROR_PATTERNS: ErrorPattern[] = [
|
|
11
|
+
{
|
|
12
|
+
pattern: /timeout/i,
|
|
13
|
+
message:
|
|
14
|
+
"The operation took too long. Try a simpler request or break it into smaller steps.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /too many requests|rate limit|retry after/i,
|
|
18
|
+
message: "Claude is busy right now. Please wait a moment and try again.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pattern: /etimedout|econnreset|enotfound/i,
|
|
22
|
+
message: "Connection issue. Please check your network and try again.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
pattern: /cancelled|aborted/i,
|
|
26
|
+
message: "Request was cancelled.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /unsafe command|blocked/i,
|
|
30
|
+
message: "That operation isn't allowed for safety reasons.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
pattern: /file access|outside allowed paths/i,
|
|
34
|
+
message: "Claude can't access that file location.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pattern: /authentication|unauthorized|401/i,
|
|
38
|
+
message: "Authentication issue. Please check your credentials.",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert technical errors to user-friendly messages.
|
|
44
|
+
*/
|
|
45
|
+
export function formatUserError(error: Error): string {
|
|
46
|
+
const errorStr = error.message || String(error);
|
|
47
|
+
|
|
48
|
+
for (const { pattern, message } of ERROR_PATTERNS) {
|
|
49
|
+
if (pattern.test(errorStr)) {
|
|
50
|
+
return message;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generic fallback with truncation
|
|
55
|
+
const truncated =
|
|
56
|
+
errorStr.length > 200 ? errorStr.slice(0, 200) + "..." : errorStr;
|
|
57
|
+
return `Error: ${truncated || "An unexpected error occurred"}`;
|
|
58
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight event emitter for decoupling modules.
|
|
3
|
+
* Eliminates circular dependencies between session and utils.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type EventCallback<T> = (data: T) => void;
|
|
7
|
+
|
|
8
|
+
interface BotEventsMap {
|
|
9
|
+
sessionRunning: boolean;
|
|
10
|
+
stopRequested: undefined;
|
|
11
|
+
interruptRequested: undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class BotEventEmitter {
|
|
15
|
+
private listeners = new Map<
|
|
16
|
+
keyof BotEventsMap,
|
|
17
|
+
Set<EventCallback<unknown>>
|
|
18
|
+
>();
|
|
19
|
+
private sessionRunning = false;
|
|
20
|
+
|
|
21
|
+
on<K extends keyof BotEventsMap>(
|
|
22
|
+
event: K,
|
|
23
|
+
callback: EventCallback<BotEventsMap[K]>,
|
|
24
|
+
): () => void {
|
|
25
|
+
if (!this.listeners.has(event)) {
|
|
26
|
+
this.listeners.set(event, new Set());
|
|
27
|
+
}
|
|
28
|
+
this.listeners.get(event)?.add(callback as EventCallback<unknown>);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
this.listeners.get(event)?.delete(callback as EventCallback<unknown>);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
emit<K extends keyof BotEventsMap>(event: K, data: BotEventsMap[K]): void {
|
|
36
|
+
if (event === "sessionRunning") {
|
|
37
|
+
this.sessionRunning = data as boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const callbacks = this.listeners.get(event);
|
|
41
|
+
if (callbacks) {
|
|
42
|
+
for (const callback of callbacks) {
|
|
43
|
+
try {
|
|
44
|
+
callback(data);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(`Event handler error for ${event}:`, error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getSessionState(): boolean {
|
|
53
|
+
return this.sessionRunning;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const botEvents = new BotEventEmitter();
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TELEGRAM_SAFE_LIMIT,
|
|
15
15
|
} from "../config";
|
|
16
16
|
import { convertMarkdownToHtml, escapeHtml } from "../formatting";
|
|
17
|
+
import { safeTelegramCall } from "../telegram-api";
|
|
17
18
|
import type { StatusCallback } from "../types";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -87,6 +88,7 @@ export class StreamingState {
|
|
|
87
88
|
toolMessages: Message[] = []; // ephemeral tool status messages
|
|
88
89
|
lastEditTimes = new Map<number, number>(); // segment_id -> last edit time
|
|
89
90
|
lastContent = new Map<number, string>(); // segment_id -> last sent content
|
|
91
|
+
toolStartTime: number | null = null; // timestamp when current tool started
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/**
|
|
@@ -108,6 +110,7 @@ export function createStatusCallback(
|
|
|
108
110
|
});
|
|
109
111
|
state.toolMessages.push(thinkingMsg);
|
|
110
112
|
} else if (statusType === "tool") {
|
|
113
|
+
state.toolStartTime = Date.now();
|
|
111
114
|
const toolMsg = await ctx.reply(content, { parse_mode: "HTML" });
|
|
112
115
|
state.toolMessages.push(toolMsg);
|
|
113
116
|
} else if (statusType === "text" && segmentId !== undefined) {
|
|
@@ -145,28 +148,28 @@ export function createStatusCallback(
|
|
|
145
148
|
if (formatted === state.lastContent.get(segmentId)) {
|
|
146
149
|
return;
|
|
147
150
|
}
|
|
148
|
-
|
|
149
|
-
await ctx.api.editMessageText(
|
|
150
|
-
msg.chat.id,
|
|
151
|
-
msg.message_id,
|
|
152
|
-
formatted,
|
|
153
|
-
{
|
|
154
|
-
parse_mode: "HTML",
|
|
155
|
-
},
|
|
156
|
-
);
|
|
157
|
-
state.lastContent.set(segmentId, formatted);
|
|
158
|
-
} catch (htmlError) {
|
|
159
|
-
console.debug("HTML edit failed, trying plain text:", htmlError);
|
|
151
|
+
const editResult = await safeTelegramCall("editMessage", async () => {
|
|
160
152
|
try {
|
|
161
153
|
await ctx.api.editMessageText(
|
|
162
154
|
msg.chat.id,
|
|
163
155
|
msg.message_id,
|
|
164
156
|
formatted,
|
|
157
|
+
{ parse_mode: "HTML" },
|
|
165
158
|
);
|
|
166
|
-
|
|
167
|
-
} catch (
|
|
168
|
-
|
|
159
|
+
return true;
|
|
160
|
+
} catch (htmlError) {
|
|
161
|
+
// HTML parse failed, try plain text
|
|
162
|
+
console.debug("HTML edit failed, trying plain text:", htmlError);
|
|
163
|
+
await ctx.api.editMessageText(
|
|
164
|
+
msg.chat.id,
|
|
165
|
+
msg.message_id,
|
|
166
|
+
formatted,
|
|
167
|
+
);
|
|
168
|
+
return true;
|
|
169
169
|
}
|
|
170
|
+
});
|
|
171
|
+
if (editResult) {
|
|
172
|
+
state.lastContent.set(segmentId, formatted);
|
|
170
173
|
}
|
|
171
174
|
state.lastEditTimes.set(segmentId, now);
|
|
172
175
|
}
|
|
@@ -181,18 +184,11 @@ export function createStatusCallback(
|
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
if (formatted.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
{
|
|
190
|
-
parse_mode: "HTML",
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
} catch (error) {
|
|
194
|
-
console.debug("Failed to edit final message:", error);
|
|
195
|
-
}
|
|
187
|
+
await safeTelegramCall("editFinalMessage", () =>
|
|
188
|
+
ctx.api.editMessageText(msg.chat.id, msg.message_id, formatted, {
|
|
189
|
+
parse_mode: "HTML",
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
196
192
|
} else {
|
|
197
193
|
// Too long - delete and split
|
|
198
194
|
try {
|
package/src/handlers/text.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import type { Context } from "grammy";
|
|
7
7
|
import { ALLOWED_USERS } from "../config";
|
|
8
|
+
import { formatUserError } from "../errors";
|
|
8
9
|
import { isAuthorized, rateLimiter } from "../security";
|
|
9
10
|
import { session } from "../session";
|
|
10
11
|
import {
|
|
@@ -194,7 +195,7 @@ export async function handleText(ctx: Context): Promise<void> {
|
|
|
194
195
|
await ctx.reply("🛑 Query stopped.");
|
|
195
196
|
}
|
|
196
197
|
} else {
|
|
197
|
-
await ctx.reply(`❌
|
|
198
|
+
await ctx.reply(`❌ ${formatUserError(error as Error)}`);
|
|
198
199
|
}
|
|
199
200
|
break; // Exit loop after handling error
|
|
200
201
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
handleUndo,
|
|
38
38
|
handleVoice,
|
|
39
39
|
} from "./handlers";
|
|
40
|
+
import { session } from "./session";
|
|
40
41
|
|
|
41
42
|
// Create bot instance
|
|
42
43
|
const bot = new Bot(TELEGRAM_TOKEN);
|
|
@@ -149,21 +150,38 @@ if (existsSync(RESTART_FILE)) {
|
|
|
149
150
|
const runner = run(bot);
|
|
150
151
|
|
|
151
152
|
// Graceful shutdown
|
|
152
|
-
const
|
|
153
|
-
if (runner.isRunning()) {
|
|
154
|
-
console.log("Stopping bot...");
|
|
155
|
-
runner.stop();
|
|
156
|
-
}
|
|
157
|
-
};
|
|
153
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
158
154
|
|
|
159
|
-
|
|
160
|
-
console.log(
|
|
161
|
-
stopRunner();
|
|
162
|
-
process.exit(0);
|
|
163
|
-
});
|
|
155
|
+
async function gracefulShutdown(signal: string): Promise<void> {
|
|
156
|
+
console.log(`\n${signal} received - initiating graceful shutdown...`);
|
|
164
157
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
});
|
|
158
|
+
// Set a hard timeout
|
|
159
|
+
const forceExit = setTimeout(() => {
|
|
160
|
+
console.error("Shutdown timeout - forcing exit");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Stop the runner (stops polling)
|
|
166
|
+
if (runner.isRunning()) {
|
|
167
|
+
runner.stop();
|
|
168
|
+
console.log("Bot stopped");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Flush session data
|
|
172
|
+
session.flushSession();
|
|
173
|
+
console.log("Session flushed");
|
|
174
|
+
|
|
175
|
+
// Clear the timeout and exit cleanly
|
|
176
|
+
clearTimeout(forceExit);
|
|
177
|
+
console.log("Shutdown complete");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error("Error during shutdown:", error);
|
|
181
|
+
clearTimeout(forceExit);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
187
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
package/src/session.ts
CHANGED
|
@@ -25,11 +25,14 @@ import {
|
|
|
25
25
|
THINKING_KEYWORDS,
|
|
26
26
|
WORKING_DIR,
|
|
27
27
|
} from "./config";
|
|
28
|
+
import { botEvents } from "./events";
|
|
28
29
|
import { formatToolStatus } from "./formatting";
|
|
29
30
|
import { checkPendingAskUserRequests } from "./handlers/streaming";
|
|
30
31
|
import { checkCommandSafety, isPathAllowed } from "./security";
|
|
31
32
|
import type { SessionData, StatusCallback, TokenUsage } from "./types";
|
|
32
33
|
|
|
34
|
+
const SESSION_VERSION = 1;
|
|
35
|
+
|
|
33
36
|
/**
|
|
34
37
|
* Determine thinking token budget based on message keywords.
|
|
35
38
|
* Exported for testing.
|
|
@@ -96,6 +99,11 @@ class ClaudeSession {
|
|
|
96
99
|
private _queryInstance: Query | null = null;
|
|
97
100
|
private _userMessageUuids: string[] = [];
|
|
98
101
|
|
|
102
|
+
// Debounced session saving
|
|
103
|
+
private _saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
private _pendingSave = false;
|
|
105
|
+
private readonly SAVE_DEBOUNCE_MS = 500;
|
|
106
|
+
|
|
99
107
|
// Mutable working directory (can be changed with /cd)
|
|
100
108
|
private _workingDir: string = WORKING_DIR;
|
|
101
109
|
|
|
@@ -105,6 +113,15 @@ class ClaudeSession {
|
|
|
105
113
|
private _isProcessing = false;
|
|
106
114
|
private _wasInterruptedByNewMessage = false;
|
|
107
115
|
|
|
116
|
+
constructor() {
|
|
117
|
+
botEvents.on("interruptRequested", () => {
|
|
118
|
+
if (this.isRunning) {
|
|
119
|
+
this.markInterrupt();
|
|
120
|
+
this.stop();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
get workingDir(): string {
|
|
109
126
|
return this._workingDir;
|
|
110
127
|
}
|
|
@@ -293,6 +310,7 @@ class ClaudeSession {
|
|
|
293
310
|
// Create abort controller for cancellation
|
|
294
311
|
this.abortController = new AbortController();
|
|
295
312
|
this.isQueryRunning = true;
|
|
313
|
+
botEvents.emit("sessionRunning", true);
|
|
296
314
|
this.stopRequested = false;
|
|
297
315
|
this.queryStarted = new Date();
|
|
298
316
|
this.currentTool = null;
|
|
@@ -511,6 +529,7 @@ class ClaudeSession {
|
|
|
511
529
|
}
|
|
512
530
|
} finally {
|
|
513
531
|
this.isQueryRunning = false;
|
|
532
|
+
botEvents.emit("sessionRunning", false);
|
|
514
533
|
this.abortController = null;
|
|
515
534
|
this.queryStarted = null;
|
|
516
535
|
this.currentTool = null;
|
|
@@ -630,13 +649,34 @@ class ClaudeSession {
|
|
|
630
649
|
}
|
|
631
650
|
|
|
632
651
|
/**
|
|
633
|
-
* Save session to disk for resume after restart.
|
|
652
|
+
* Save session to disk for resume after restart (debounced).
|
|
653
|
+
* Multiple calls within SAVE_DEBOUNCE_MS will only result in one write.
|
|
634
654
|
*/
|
|
635
655
|
private saveSession(): void {
|
|
636
656
|
if (!this.sessionId) return;
|
|
637
657
|
|
|
658
|
+
this._pendingSave = true;
|
|
659
|
+
|
|
660
|
+
// Clear existing timeout if any
|
|
661
|
+
if (this._saveTimeout) {
|
|
662
|
+
clearTimeout(this._saveTimeout);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Schedule the actual write
|
|
666
|
+
this._saveTimeout = setTimeout(() => {
|
|
667
|
+
this._doSaveSession();
|
|
668
|
+
}, this.SAVE_DEBOUNCE_MS);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Actually perform the session write to disk.
|
|
673
|
+
*/
|
|
674
|
+
private _doSaveSession(): void {
|
|
675
|
+
if (!this.sessionId || !this._pendingSave) return;
|
|
676
|
+
|
|
638
677
|
try {
|
|
639
678
|
const data: SessionData = {
|
|
679
|
+
version: SESSION_VERSION,
|
|
640
680
|
session_id: this.sessionId,
|
|
641
681
|
saved_at: new Date().toISOString(),
|
|
642
682
|
working_dir: this._workingDir,
|
|
@@ -645,6 +685,25 @@ class ClaudeSession {
|
|
|
645
685
|
console.log(`Session saved to ${SESSION_FILE}`);
|
|
646
686
|
} catch (error) {
|
|
647
687
|
console.warn(`Failed to save session: ${error}`);
|
|
688
|
+
} finally {
|
|
689
|
+
this._pendingSave = false;
|
|
690
|
+
this._saveTimeout = null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Immediately flush any pending session save to disk.
|
|
696
|
+
* Use this for graceful shutdown to ensure session is persisted.
|
|
697
|
+
*/
|
|
698
|
+
flushSession(): void {
|
|
699
|
+
if (this._saveTimeout) {
|
|
700
|
+
clearTimeout(this._saveTimeout);
|
|
701
|
+
this._saveTimeout = null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (this._pendingSave || this.sessionId) {
|
|
705
|
+
this._pendingSave = true; // Ensure _doSaveSession runs
|
|
706
|
+
this._doSaveSession();
|
|
648
707
|
}
|
|
649
708
|
}
|
|
650
709
|
|
|
@@ -665,6 +724,13 @@ class ClaudeSession {
|
|
|
665
724
|
return [false, "Saved session file is empty"];
|
|
666
725
|
}
|
|
667
726
|
|
|
727
|
+
if (data.version !== SESSION_VERSION) {
|
|
728
|
+
return [
|
|
729
|
+
false,
|
|
730
|
+
`Session version mismatch (found v${data.version ?? 0}, expected v${SESSION_VERSION})`,
|
|
731
|
+
];
|
|
732
|
+
}
|
|
733
|
+
|
|
668
734
|
if (data.working_dir && data.working_dir !== this._workingDir) {
|
|
669
735
|
return [
|
|
670
736
|
false,
|