ctb 1.0.0 → 1.2.1
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 +42 -11
- package/package.json +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +377 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/session.test.ts +399 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +41 -0
- package/src/cli.ts +94 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +417 -3
- package/src/handlers/index.ts +8 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +19 -0
- package/src/session.ts +140 -6
package/src/handlers/text.ts
CHANGED
|
@@ -2,127 +2,205 @@
|
|
|
2
2
|
* Text message handler for Claude Telegram Bot.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
5
6
|
import type { Context } from "grammy";
|
|
6
|
-
import { session } from "../session";
|
|
7
7
|
import { ALLOWED_USERS } from "../config";
|
|
8
8
|
import { isAuthorized, rateLimiter } from "../security";
|
|
9
|
+
import { session } from "../session";
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
auditLog,
|
|
12
|
+
auditLogRateLimit,
|
|
13
|
+
checkInterrupt,
|
|
14
|
+
startTypingIndicator,
|
|
14
15
|
} from "../utils";
|
|
15
|
-
import {
|
|
16
|
+
import { createStatusCallback, StreamingState } from "./streaming";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Execute a shell command and return output.
|
|
20
|
+
*/
|
|
21
|
+
async function execShellCommand(
|
|
22
|
+
command: string,
|
|
23
|
+
cwd: string,
|
|
24
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const proc = spawn("bash", ["-c", command], {
|
|
27
|
+
cwd,
|
|
28
|
+
timeout: 30000, // 30s timeout
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
|
|
34
|
+
proc.stdout.on("data", (data) => {
|
|
35
|
+
stdout += data.toString();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
proc.stderr.on("data", (data) => {
|
|
39
|
+
stderr += data.toString();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
proc.on("close", (code) => {
|
|
43
|
+
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
proc.on("error", (err) => {
|
|
47
|
+
resolve({ stdout, stderr: err.message, exitCode: 1 });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
16
51
|
|
|
17
52
|
/**
|
|
18
53
|
* Handle incoming text messages.
|
|
19
54
|
*/
|
|
20
55
|
export async function handleText(ctx: Context): Promise<void> {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
56
|
+
const userId = ctx.from?.id;
|
|
57
|
+
const username = ctx.from?.username || "unknown";
|
|
58
|
+
const chatId = ctx.chat?.id;
|
|
59
|
+
let message = ctx.message?.text;
|
|
60
|
+
|
|
61
|
+
if (!userId || !message || !chatId) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 1. Authorization check
|
|
66
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
67
|
+
await ctx.reply("Unauthorized. Contact the bot owner for access.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Shell command shortcut: !command executes directly
|
|
72
|
+
if (message.startsWith("!")) {
|
|
73
|
+
const shellCmd = message.slice(1).trim();
|
|
74
|
+
if (shellCmd) {
|
|
75
|
+
const cwd = session.workingDir;
|
|
76
|
+
await ctx.reply(
|
|
77
|
+
`⚡ Running in <code>${cwd}</code>:\n<code>${shellCmd}</code>`,
|
|
78
|
+
{
|
|
79
|
+
parse_mode: "HTML",
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const { stdout, stderr, exitCode } = await execShellCommand(
|
|
84
|
+
shellCmd,
|
|
85
|
+
cwd,
|
|
86
|
+
);
|
|
87
|
+
const output = (stdout + stderr).trim();
|
|
88
|
+
const maxLen = 4000;
|
|
89
|
+
const truncated =
|
|
90
|
+
output.length > maxLen
|
|
91
|
+
? `${output.slice(0, maxLen)}...(truncated)`
|
|
92
|
+
: output;
|
|
93
|
+
|
|
94
|
+
if (exitCode === 0) {
|
|
95
|
+
await ctx.reply(
|
|
96
|
+
`✅ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
|
|
97
|
+
{
|
|
98
|
+
parse_mode: "HTML",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
await ctx.reply(
|
|
103
|
+
`❌ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
|
|
104
|
+
{
|
|
105
|
+
parse_mode: "HTML",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await auditLog(userId, username, "SHELL", shellCmd, `exit=${exitCode}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Check for interrupt prefix
|
|
115
|
+
message = await checkInterrupt(message);
|
|
116
|
+
if (!message.trim()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Rate limit check
|
|
121
|
+
const [allowed, retryAfter] = rateLimiter.check(userId);
|
|
122
|
+
if (!allowed) {
|
|
123
|
+
await auditLogRateLimit(userId, username, retryAfter!);
|
|
124
|
+
await ctx.reply(
|
|
125
|
+
`⏳ Rate limited. Please wait ${retryAfter?.toFixed(1)} seconds.`,
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Store message for retry
|
|
131
|
+
session.lastMessage = message;
|
|
132
|
+
|
|
133
|
+
// 5. Mark processing started
|
|
134
|
+
const stopProcessing = session.startProcessing();
|
|
135
|
+
|
|
136
|
+
// 6. Start typing indicator
|
|
137
|
+
const typing = startTypingIndicator(ctx);
|
|
138
|
+
|
|
139
|
+
// 7. Create streaming state and callback
|
|
140
|
+
let state = new StreamingState();
|
|
141
|
+
let statusCallback = createStatusCallback(ctx, state);
|
|
142
|
+
|
|
143
|
+
// 8. Send to Claude with retry logic for crashes
|
|
144
|
+
const MAX_RETRIES = 1;
|
|
145
|
+
|
|
146
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
147
|
+
try {
|
|
148
|
+
const response = await session.sendMessageStreaming(
|
|
149
|
+
message,
|
|
150
|
+
username,
|
|
151
|
+
userId,
|
|
152
|
+
statusCallback,
|
|
153
|
+
chatId,
|
|
154
|
+
ctx,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// 9. Audit log
|
|
158
|
+
await auditLog(userId, username, "TEXT", message, response);
|
|
159
|
+
break; // Success - exit retry loop
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const errorStr = String(error);
|
|
162
|
+
const isClaudeCodeCrash = errorStr.includes("exited with code");
|
|
163
|
+
|
|
164
|
+
// Clean up any partial messages from this attempt
|
|
165
|
+
for (const toolMsg of state.toolMessages) {
|
|
166
|
+
try {
|
|
167
|
+
await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore cleanup errors
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Retry on Claude Code crash (not user cancellation)
|
|
174
|
+
if (isClaudeCodeCrash && attempt < MAX_RETRIES) {
|
|
175
|
+
console.log(
|
|
176
|
+
`Claude Code crashed, retrying (attempt ${attempt + 2}/${MAX_RETRIES + 1})...`,
|
|
177
|
+
);
|
|
178
|
+
await session.kill(); // Clear corrupted session
|
|
179
|
+
await ctx.reply(`⚠️ Claude crashed, retrying...`);
|
|
180
|
+
// Reset state for retry
|
|
181
|
+
state = new StreamingState();
|
|
182
|
+
statusCallback = createStatusCallback(ctx, state);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Final attempt failed or non-retryable error
|
|
187
|
+
console.error("Error processing message:", error);
|
|
188
|
+
|
|
189
|
+
// Check if it was a cancellation
|
|
190
|
+
if (errorStr.includes("abort") || errorStr.includes("cancel")) {
|
|
191
|
+
// Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
|
|
192
|
+
const wasInterrupt = session.consumeInterruptFlag();
|
|
193
|
+
if (!wasInterrupt) {
|
|
194
|
+
await ctx.reply("🛑 Query stopped.");
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
|
|
198
|
+
}
|
|
199
|
+
break; // Exit loop after handling error
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 10. Cleanup
|
|
204
|
+
stopProcessing();
|
|
205
|
+
typing.stop();
|
|
128
206
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,16 +17,24 @@ import {
|
|
|
17
17
|
handleBookmarks,
|
|
18
18
|
handleCallback,
|
|
19
19
|
handleCd,
|
|
20
|
+
handleCompact,
|
|
21
|
+
handleCost,
|
|
20
22
|
handleDocument,
|
|
23
|
+
handleFile,
|
|
24
|
+
handleModel,
|
|
21
25
|
handleNew,
|
|
22
26
|
handlePhoto,
|
|
27
|
+
handlePlan,
|
|
23
28
|
handleRestart,
|
|
24
29
|
handleResume,
|
|
25
30
|
handleRetry,
|
|
31
|
+
handleSkill,
|
|
26
32
|
handleStart,
|
|
27
33
|
handleStatus,
|
|
28
34
|
handleStop,
|
|
29
35
|
handleText,
|
|
36
|
+
handleThink,
|
|
37
|
+
handleUndo,
|
|
30
38
|
handleVoice,
|
|
31
39
|
} from "./handlers";
|
|
32
40
|
|
|
@@ -59,11 +67,22 @@ bot.use(
|
|
|
59
67
|
bot.command("start", handleStart);
|
|
60
68
|
bot.command("new", handleNew);
|
|
61
69
|
bot.command("stop", handleStop);
|
|
70
|
+
bot.command("c", handleStop);
|
|
71
|
+
bot.command("kill", handleStop);
|
|
72
|
+
bot.command("dc", handleStop);
|
|
62
73
|
bot.command("status", handleStatus);
|
|
63
74
|
bot.command("resume", handleResume);
|
|
64
75
|
bot.command("restart", handleRestart);
|
|
65
76
|
bot.command("retry", handleRetry);
|
|
66
77
|
bot.command("cd", handleCd);
|
|
78
|
+
bot.command("skill", handleSkill);
|
|
79
|
+
bot.command("file", handleFile);
|
|
80
|
+
bot.command("model", handleModel);
|
|
81
|
+
bot.command("cost", handleCost);
|
|
82
|
+
bot.command("think", handleThink);
|
|
83
|
+
bot.command("plan", handlePlan);
|
|
84
|
+
bot.command("compact", handleCompact);
|
|
85
|
+
bot.command("undo", handleUndo);
|
|
67
86
|
bot.command("bookmarks", handleBookmarks);
|
|
68
87
|
|
|
69
88
|
// ============== Message Handlers ==============
|
package/src/session.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import {
|
|
10
10
|
type Options,
|
|
11
|
+
type Query,
|
|
11
12
|
query,
|
|
12
13
|
type SDKMessage,
|
|
13
14
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -31,8 +32,9 @@ import type { SessionData, StatusCallback, TokenUsage } from "./types";
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Determine thinking token budget based on message keywords.
|
|
35
|
+
* Exported for testing.
|
|
34
36
|
*/
|
|
35
|
-
function getThinkingLevel(message: string): number {
|
|
37
|
+
export function getThinkingLevel(message: string): number {
|
|
36
38
|
const msgLower = message.toLowerCase();
|
|
37
39
|
|
|
38
40
|
// Check deep thinking triggers first (more specific)
|
|
@@ -78,6 +80,21 @@ class ClaudeSession {
|
|
|
78
80
|
lastErrorTime: Date | null = null;
|
|
79
81
|
lastUsage: TokenUsage | null = null;
|
|
80
82
|
lastMessage: string | null = null;
|
|
83
|
+
lastBotResponse: string | null = null;
|
|
84
|
+
|
|
85
|
+
// Model and mode settings
|
|
86
|
+
currentModel: "sonnet" | "opus" | "haiku" = "sonnet";
|
|
87
|
+
forceThinking: number | null = null; // Tokens for next message, then resets
|
|
88
|
+
planMode = false;
|
|
89
|
+
|
|
90
|
+
// Cumulative usage tracking
|
|
91
|
+
totalInputTokens = 0;
|
|
92
|
+
totalOutputTokens = 0;
|
|
93
|
+
totalCacheReadTokens = 0;
|
|
94
|
+
|
|
95
|
+
// File checkpointing for /undo
|
|
96
|
+
private _queryInstance: Query | null = null;
|
|
97
|
+
private _userMessageUuids: string[] = [];
|
|
81
98
|
|
|
82
99
|
// Mutable working directory (can be changed with /cd)
|
|
83
100
|
private _workingDir: string = WORKING_DIR;
|
|
@@ -192,11 +209,27 @@ class ClaudeSession {
|
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
const isNewSession = !this.isActive;
|
|
195
|
-
|
|
212
|
+
|
|
213
|
+
// Determine thinking tokens - forceThinking overrides keyword detection
|
|
214
|
+
let thinkingTokens: number;
|
|
215
|
+
if (this.forceThinking !== null) {
|
|
216
|
+
thinkingTokens = this.forceThinking;
|
|
217
|
+
this.forceThinking = null; // Reset after use
|
|
218
|
+
} else {
|
|
219
|
+
thinkingTokens = getThinkingLevel(message);
|
|
220
|
+
}
|
|
196
221
|
const thinkingLabel =
|
|
197
222
|
{ 0: "off", 10000: "normal", 50000: "deep" }[thinkingTokens] ||
|
|
198
223
|
String(thinkingTokens);
|
|
199
224
|
|
|
225
|
+
// Determine model based on currentModel setting
|
|
226
|
+
const modelMap = {
|
|
227
|
+
sonnet: "claude-sonnet-4-5",
|
|
228
|
+
opus: "claude-opus-4-5",
|
|
229
|
+
haiku: "claude-haiku-3-5",
|
|
230
|
+
};
|
|
231
|
+
const modelId = modelMap[this.currentModel];
|
|
232
|
+
|
|
200
233
|
// Inject current date/time at session start so Claude doesn't need to call a tool for it
|
|
201
234
|
let messageToSend = message;
|
|
202
235
|
if (isNewSession) {
|
|
@@ -218,16 +251,17 @@ class ClaudeSession {
|
|
|
218
251
|
|
|
219
252
|
// Build SDK V1 options - supports all features
|
|
220
253
|
const options: Options = {
|
|
221
|
-
model:
|
|
254
|
+
model: modelId,
|
|
222
255
|
cwd: this._workingDir,
|
|
223
256
|
settingSources: ["user", "project"],
|
|
224
|
-
permissionMode: "bypassPermissions",
|
|
225
|
-
allowDangerouslySkipPermissions:
|
|
257
|
+
permissionMode: this.planMode ? "plan" : "bypassPermissions",
|
|
258
|
+
allowDangerouslySkipPermissions: !this.planMode,
|
|
226
259
|
systemPrompt: SAFETY_PROMPT,
|
|
227
260
|
mcpServers: MCP_SERVERS,
|
|
228
261
|
maxThinkingTokens: thinkingTokens,
|
|
229
262
|
additionalDirectories: ALLOWED_PATHS,
|
|
230
263
|
resume: this.sessionId || undefined,
|
|
264
|
+
enableFileCheckpointing: true, // Enable /undo support
|
|
231
265
|
};
|
|
232
266
|
|
|
233
267
|
// Add Claude Code executable path if set (required for standalone builds)
|
|
@@ -281,6 +315,9 @@ class ClaudeSession {
|
|
|
281
315
|
},
|
|
282
316
|
});
|
|
283
317
|
|
|
318
|
+
// Store query instance for /undo support
|
|
319
|
+
this._queryInstance = queryInstance;
|
|
320
|
+
|
|
284
321
|
// Process streaming response
|
|
285
322
|
for await (const event of queryInstance) {
|
|
286
323
|
// Check for abort
|
|
@@ -308,6 +345,12 @@ class ClaudeSession {
|
|
|
308
345
|
this.saveSession();
|
|
309
346
|
}
|
|
310
347
|
|
|
348
|
+
// Capture user message UUIDs for /undo checkpoints
|
|
349
|
+
if (event.type === "user" && event.uuid) {
|
|
350
|
+
this._userMessageUuids.push(event.uuid);
|
|
351
|
+
console.log(`Checkpoint: user message ${event.uuid.slice(0, 8)}...`);
|
|
352
|
+
}
|
|
353
|
+
|
|
311
354
|
// Handle different message types
|
|
312
355
|
if (event.type === "assistant") {
|
|
313
356
|
for (const block of event.message.content) {
|
|
@@ -436,6 +479,10 @@ class ClaudeSession {
|
|
|
436
479
|
if ("usage" in event && event.usage) {
|
|
437
480
|
this.lastUsage = event.usage as TokenUsage;
|
|
438
481
|
const u = this.lastUsage;
|
|
482
|
+
// Accumulate totals
|
|
483
|
+
this.totalInputTokens += u.input_tokens || 0;
|
|
484
|
+
this.totalOutputTokens += u.output_tokens || 0;
|
|
485
|
+
this.totalCacheReadTokens += u.cache_read_input_tokens || 0;
|
|
439
486
|
console.log(
|
|
440
487
|
`Usage: in=${u.input_tokens} out=${u.output_tokens} cache_read=${
|
|
441
488
|
u.cache_read_input_tokens || 0
|
|
@@ -486,7 +533,9 @@ class ClaudeSession {
|
|
|
486
533
|
|
|
487
534
|
await statusCallback("done", "");
|
|
488
535
|
|
|
489
|
-
|
|
536
|
+
const finalResponse = responseParts.join("") || "No response from Claude.";
|
|
537
|
+
this.lastBotResponse = finalResponse;
|
|
538
|
+
return finalResponse;
|
|
490
539
|
}
|
|
491
540
|
|
|
492
541
|
/**
|
|
@@ -495,9 +544,91 @@ class ClaudeSession {
|
|
|
495
544
|
async kill(): Promise<void> {
|
|
496
545
|
this.sessionId = null;
|
|
497
546
|
this.lastActivity = null;
|
|
547
|
+
// Reset usage totals
|
|
548
|
+
this.totalInputTokens = 0;
|
|
549
|
+
this.totalOutputTokens = 0;
|
|
550
|
+
this.totalCacheReadTokens = 0;
|
|
551
|
+
// Reset modes
|
|
552
|
+
this.planMode = false;
|
|
553
|
+
this.forceThinking = null;
|
|
554
|
+
// Reset checkpoints
|
|
555
|
+
this._queryInstance = null;
|
|
556
|
+
this._userMessageUuids = [];
|
|
498
557
|
console.log("Session cleared");
|
|
499
558
|
}
|
|
500
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Check if undo is available (has checkpoints).
|
|
562
|
+
*/
|
|
563
|
+
get canUndo(): boolean {
|
|
564
|
+
return this._queryInstance !== null && this._userMessageUuids.length > 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Get number of available undo checkpoints.
|
|
569
|
+
*/
|
|
570
|
+
get undoCheckpoints(): number {
|
|
571
|
+
return this._userMessageUuids.length;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Undo file changes by rewinding to the last user message checkpoint.
|
|
576
|
+
* Returns [success, message].
|
|
577
|
+
*/
|
|
578
|
+
async undo(): Promise<[boolean, string]> {
|
|
579
|
+
if (!this._queryInstance) {
|
|
580
|
+
return [false, "No active session to undo"];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (this._userMessageUuids.length === 0) {
|
|
584
|
+
return [false, "No checkpoints available"];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Get and remove the last user message UUID
|
|
588
|
+
const targetUuid = this._userMessageUuids.pop();
|
|
589
|
+
if (!targetUuid) {
|
|
590
|
+
return [false, "No checkpoints available"];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
console.log(`Rewinding files to checkpoint ${targetUuid.slice(0, 8)}...`);
|
|
595
|
+
await this._queryInstance.rewindFiles(targetUuid);
|
|
596
|
+
|
|
597
|
+
const remaining = this._userMessageUuids.length;
|
|
598
|
+
return [
|
|
599
|
+
true,
|
|
600
|
+
`✅ Reverted file changes to checkpoint \`${targetUuid.slice(0, 8)}...\`\n${remaining} checkpoint${remaining !== 1 ? "s" : ""} remaining`,
|
|
601
|
+
];
|
|
602
|
+
} catch (error) {
|
|
603
|
+
// Restore the checkpoint on failure
|
|
604
|
+
this._userMessageUuids.push(targetUuid);
|
|
605
|
+
console.error(`Undo failed: ${error}`);
|
|
606
|
+
return [false, `Failed to undo: ${error}`];
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Estimate cost based on current model and usage.
|
|
612
|
+
*/
|
|
613
|
+
estimateCost(): { inputCost: number; outputCost: number; total: number } {
|
|
614
|
+
// Pricing per 1M tokens (approximate as of 2024)
|
|
615
|
+
const pricing = {
|
|
616
|
+
sonnet: { input: 3, output: 15 },
|
|
617
|
+
opus: { input: 15, output: 75 },
|
|
618
|
+
haiku: { input: 0.25, output: 1.25 },
|
|
619
|
+
};
|
|
620
|
+
const rates = pricing[this.currentModel];
|
|
621
|
+
|
|
622
|
+
const inputCost = (this.totalInputTokens / 1_000_000) * rates.input;
|
|
623
|
+
const outputCost = (this.totalOutputTokens / 1_000_000) * rates.output;
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
inputCost,
|
|
627
|
+
outputCost,
|
|
628
|
+
total: inputCost + outputCost,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
501
632
|
/**
|
|
502
633
|
* Save session to disk for resume after restart.
|
|
503
634
|
*/
|
|
@@ -561,5 +692,8 @@ class ClaudeSession {
|
|
|
561
692
|
}
|
|
562
693
|
}
|
|
563
694
|
|
|
695
|
+
// Export class for testing
|
|
696
|
+
export { ClaudeSession };
|
|
697
|
+
|
|
564
698
|
// Global session instance
|
|
565
699
|
export const session = new ClaudeSession();
|