chatccc 0.2.4 → 0.2.6

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/src/session.ts CHANGED
@@ -1,456 +1,514 @@
1
- import { getSessionInfo, unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk";
2
-
3
- import {
4
- CLAUDE_EFFORT,
5
- CLAUDE_MODEL,
6
- anthropicConfigDisplay,
7
- fileLog,
8
- getDefaultCwd,
9
- isSdkAnthropicDefault,
10
- ts,
11
- } from "./config.ts";
12
- import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
13
- import {
14
- createCardKitCard,
15
- sendCardKitMessage,
16
- updateCardKitCard,
17
- } from "./cardkit.ts";
18
- import { sendTextReply } from "./feishu-api.ts";
19
-
20
- // ---------------------------------------------------------------------------
21
- // Shared state (imported by index.ts)
22
- // ---------------------------------------------------------------------------
23
-
24
- export const processedMessages = new Set<string>();
25
- export const MAX_PROCESSED = 5000;
26
-
27
- export let sessionGen = 0;
28
- export const chatSessionMap = new Map<string, {
29
- gen: number;
30
- close: () => void;
31
- cardId: string | null;
32
- stopped: boolean;
33
- accumulatedContent: string;
34
- finalText: string;
35
- spinnerTimer: ReturnType<typeof setInterval> | null;
36
- msgTimestamp: number;
37
- sequence: number;
38
- cardBusy: boolean;
39
- }>();
40
-
41
- // 持久化会话信息,流式结束后不清除,供 /status 查询
42
- export const sessionInfoMap = new Map<string, {
43
- sessionId: string;
44
- turnCount: number;
45
- lastContextTokens: number;
46
- startTime: number;
47
- model: string;
48
- effort: string;
49
- }>();
50
-
51
- export function resetState(): void {
52
- for (const entry of chatSessionMap.values()) {
53
- if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
54
- try { entry.close(); } catch { /* ignore */ }
55
- }
56
- chatSessionMap.clear();
57
- sessionInfoMap.clear();
58
- processedMessages.clear();
59
- console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions)`);
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Claude session management
64
- // ---------------------------------------------------------------------------
65
-
66
- function claudeSdkSessionOptions(cwd: string): Record<string, unknown> {
67
- const o: Record<string, unknown> = {
68
- cwd,
69
- permissionMode: "bypassPermissions",
70
- allowDangerouslySkipPermissions: true,
71
- autoCompactEnabled: true,
72
- };
73
- if (!isSdkAnthropicDefault(CLAUDE_MODEL)) o.model = CLAUDE_MODEL;
74
- if (!isSdkAnthropicDefault(CLAUDE_EFFORT)) o.effort = CLAUDE_EFFORT;
75
- return o;
76
- }
77
-
78
- export async function initClaudeSession(): Promise<string> {
79
- const cwd = await getDefaultCwd();
80
- console.log(
81
- `[${ts()}] [STEP 1/5] Creating Claude session via SDK (model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}, cwd=${cwd})`
82
- );
83
-
84
- const session = unstable_v2_createSession(claudeSdkSessionOptions(cwd) as any);
85
-
86
- await session.send("ok");
87
-
88
- const stream = session.stream();
89
-
90
- const first = await stream.next();
91
- if (first.done || !(first.value as { session_id?: string }).session_id) {
92
- session.close();
93
- throw new Error("No session ID in Claude init event");
94
- }
95
-
96
- const initMsg = first.value as { session_id: string };
97
- const sessionId = initMsg.session_id;
98
- console.log(`[${ts()}] → sessionId: ${sessionId}`);
99
-
100
- (async () => {
101
- try {
102
- for await (const _msg of stream) {
103
- // 静默消费,不做额外处理
104
- }
105
- } catch {
106
- // stream 异常不阻塞主流程
107
- } finally {
108
- session.close();
109
- }
110
- })();
111
-
112
- return sessionId;
113
- }
114
-
115
- export async function resumeAndPrompt(
116
- sessionId: string,
117
- userText: string,
118
- token: string,
119
- chatId: string,
120
- msgTimestamp: number
121
- ): Promise<void> {
122
- const cwd = (await getSessionInfo(sessionId))?.cwd ?? (await getDefaultCwd());
123
- console.log(
124
- `[${ts()}] Resuming Claude session: ${sessionId} (model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}, cwd=${cwd})`
125
- );
126
-
127
- const session = unstable_v2_resumeSession(sessionId, claudeSdkSessionOptions(cwd) as any);
128
-
129
- let cardId: string | null = null;
130
- chatSessionMap.set(chatId, {
131
- gen: ++sessionGen,
132
- close: () => session.close(),
133
- cardId: null,
134
- stopped: false,
135
- accumulatedContent: "",
136
- finalText: "",
137
- spinnerTimer: null,
138
- msgTimestamp,
139
- sequence: 0,
140
- cardBusy: false,
141
- });
142
- const myGen = sessionGen;
143
-
144
- // 更新持久化会话信息
145
- const now = Date.now();
146
- const existingInfo = sessionInfoMap.get(chatId);
147
- sessionInfoMap.set(chatId, {
148
- sessionId,
149
- turnCount: (existingInfo?.turnCount ?? 0) + 1,
150
- lastContextTokens: existingInfo?.lastContextTokens ?? 0,
151
- startTime: now,
152
- model: anthropicConfigDisplay(CLAUDE_MODEL),
153
- effort: anthropicConfigDisplay(CLAUDE_EFFORT),
154
- });
155
-
156
- await session.send(userText);
157
-
158
- cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
159
- console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
160
- fileLog.flush();
161
- sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
162
- return null;
163
- });
164
- if (cardId) {
165
- const cEntry = chatSessionMap.get(chatId);
166
- if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
167
- const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
168
- console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
169
- fileLog.flush();
170
- return false;
171
- });
172
- if (!sendOk) {
173
- sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
174
- cardId = null;
175
- if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
176
- }
177
- }
178
-
179
- const stream = session.stream();
180
-
181
- let chunkCount = 0;
182
- let accumulatedContent = "";
183
- let finalText = "";
184
-
185
- let cardCreatedAt = Date.now();
186
- const CARD_ROTATE_MS = 9 * 60 * 1000; // 飞书 CardKit 流式超时 10 分钟,提前 1 分钟切换
187
-
188
- let dotCount = 0;
189
- let lastSentContent = "";
190
- let streamErrorNotified = false;
191
- let healthLogTicks = 0;
192
- const sendInterval = cardId ? setInterval(async () => {
193
- const cEntry = chatSessionMap.get(chatId);
194
- if (!cEntry || cEntry.stopped || cEntry.cardBusy) return;
195
- if (cEntry.cardId !== cardId) return;
196
-
197
- // 9 分钟超时:主动结束当前卡片流,创建新卡片继续
198
- if (Date.now() - cardCreatedAt > CARD_ROTATE_MS) {
199
- cEntry.cardBusy = true;
200
- const oldCardId = cardId;
201
- try {
202
- // 1. 用当前累积内容更新旧卡片为静态版本
203
- const oldSeqBase = cEntry.sequence;
204
- const oldDisplay = truncateContent(accumulatedContent + finalText) || "处理中...";
205
- const oldCard = buildProgressCard(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
206
- await updateCardKitCard(token, oldCardId!, oldCard, oldSeqBase + 1).catch(() => {});
207
- // 2. 创建新卡片并发送
208
- const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
209
- if (!newCardId) throw new Error("createCardKitCard returned empty");
210
- await sendCardKitMessage(token, chatId, newCardId);
211
- // 3. 切换到新卡片
212
- cardId = newCardId;
213
- cEntry.cardId = newCardId;
214
- cEntry.sequence = 1;
215
- cardCreatedAt = Date.now();
216
- lastSentContent = "";
217
- streamErrorNotified = false;
218
- console.log(`[${ts()}] [CARDIKT] rotated: old=${oldCardId} new=${newCardId} (9min timeout)`);
219
- } catch (err) {
220
- console.error(`[${ts()}] [CARDIKT] rotation FAIL: ${(err as Error).message}`);
221
- } finally {
222
- cEntry.cardBusy = false;
223
- }
224
- return;
225
- }
226
-
227
- dotCount = (dotCount % 9) + 1;
228
- const content = truncateContent(accumulatedContent + finalText + "\n" + "。".repeat(dotCount));
229
- if (content === lastSentContent) return;
230
-
231
- lastSentContent = content;
232
- cEntry.cardBusy = true;
233
- const mySeq = cEntry.sequence + 1;
234
- try {
235
- const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
236
- await updateCardKitCard(token, cardId!, card, mySeq);
237
- cEntry.sequence = mySeq;
238
- cEntry.accumulatedContent = accumulatedContent;
239
- streamErrorNotified = false;
240
- healthLogTicks++;
241
- if (healthLogTicks % 10 === 0) {
242
- console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} content=${accumulatedContent.length}chars text=${finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
243
- }
244
- } catch (err) {
245
- console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
246
- if (!streamErrorNotified) {
247
- streamErrorNotified = true;
248
- sendTextReply(token, chatId, "⚠️ 卡片更新失败,结果将以文本形式发送。").catch(() => {});
249
- }
250
- } finally {
251
- cEntry.cardBusy = false;
252
- }
253
- }, 3000) : null;
254
- if (sendInterval) {
255
- const entry = chatSessionMap.get(chatId);
256
- if (entry) entry.spinnerTimer = sendInterval;
257
- }
258
-
259
- try {
260
- for await (const msg of stream) {
261
- const sdkMsg = msg as {
262
- type?: string;
263
- message?: { content?: Array<{ type: string; thinking?: string; text?: string; name?: string; input?: unknown; tool_use_id?: string; content?: unknown; is_error?: boolean }> };
264
- };
265
- if ((sdkMsg.type === "assistant" || sdkMsg.type === "user") && sdkMsg.message?.content) {
266
- for (const block of sdkMsg.message.content) {
267
-
268
- if (block.type === "thinking" && block.thinking) {
269
- chunkCount++;
270
- accumulatedContent += block.thinking;
271
- } else if (block.type === "tool_use") {
272
- const toolName = (block as { name?: string }).name ?? "unknown";
273
- const toolInput = (block as { input?: unknown }).input;
274
- const inputStr = typeof toolInput === "object" ? JSON.stringify(toolInput) : String(toolInput ?? "");
275
- const shortInput = inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
276
- accumulatedContent += `\n\n${getToolEmoji(toolName)} **${toolName}**\n\`${shortInput}\`\n`;
277
- } else if (block.type === "tool_result") {
278
- const toolUseId = (block as { tool_use_id?: string }).tool_use_id ?? "";
279
- const resultContent = (block as { content?: unknown }).content;
280
- let resultStr = "";
281
- if (typeof resultContent === "string") {
282
- resultStr = resultContent;
283
- } else if (Array.isArray(resultContent)) {
284
- resultStr = resultContent.map((c: { type?: string; text?: string }) => c.text ?? "").join("");
285
- } else if (resultContent) {
286
- resultStr = JSON.stringify(resultContent);
287
- }
288
- const shortResult = resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
289
- const isError = (block as { is_error?: boolean }).is_error;
290
- const icon = isError ? "❌" : "✅";
291
- accumulatedContent += `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
292
- } else if (block.type === "redacted_thinking") {
293
- accumulatedContent += "\n\n⚠️ 内容被安全过滤\n";
294
- } else if (block.type === "search_result") {
295
- const searchQuery = (block as { query?: string }).query ?? "";
296
- accumulatedContent += `\n\n🔍 联网搜索: **${searchQuery}**\n`;
297
- } else if (block.type === "text" && block.text) {
298
- finalText += block.text;
299
- const entry = chatSessionMap.get(chatId);
300
- if (entry) entry.finalText = finalText;
301
- }
302
- }
303
- } else if (sdkMsg.type === "system" && (sdkMsg as { subtype?: string }).subtype === "compact_boundary") {
304
- const compactMeta = (sdkMsg as { compact_metadata?: { trigger?: string; pre_tokens?: number; post_tokens?: number } }).compact_metadata;
305
- if (compactMeta) {
306
- const triggerLabel = compactMeta.trigger === "manual" ? "手动" : "自动";
307
- accumulatedContent += `\n\n🔄 上下文压缩(${triggerLabel}): **${compactMeta.pre_tokens}** **${compactMeta.post_tokens}** tokens\n`;
308
- // 更新持久化上下文 token 数
309
- if (compactMeta.post_tokens) {
310
- const info = sessionInfoMap.get(chatId);
311
- if (info) { info.lastContextTokens = compactMeta.post_tokens; }
312
- }
313
- }
314
- }
315
- }
316
- } catch (streamErr) {
317
- console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
318
- } finally {
319
- if (sendInterval) clearInterval(sendInterval);
320
- session.close();
321
- }
322
-
323
- const cEntry = chatSessionMap.get(chatId);
324
- if (!cEntry || cEntry.gen !== myGen) return;
325
- const wasStopped = cEntry.stopped;
326
- chatSessionMap.delete(chatId);
327
-
328
- if (cardId && accumulatedContent) {
329
- while (cEntry.cardBusy) {
330
- await new Promise(r => setTimeout(r, 20));
331
- }
332
- const nextSeq = cEntry.sequence + 1;
333
- if (wasStopped) {
334
- const stopCard = buildProgressCard(accumulatedContent || "已停止", { showStop: false, headerTitle: "已停止", headerTemplate: "red" });
335
- await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {
336
- console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
337
- fileLog.flush();
338
- });
339
- } else {
340
- const doneCard = buildProgressCard(accumulatedContent, { showStop: false, headerTitle: "完成" });
341
- await updateCardKitCard(token, cardId, doneCard, nextSeq).catch((err) => {
342
- console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
343
- fileLog.flush();
344
- sendTextReply(token, chatId, "⚠️ 卡片最终更新失败。").catch(() => {});
345
- });
346
- }
347
- }
348
-
349
- // Text fallback: if CardKit streaming broke, always send full result as text
350
- if (wasStopped) {
351
- if (finalText.trim()) {
352
- await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
353
- console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
354
- );
355
- }
356
- console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${chunkCount})`);
357
- return;
358
- }
359
-
360
- if (streamErrorNotified) {
361
- // CardKit streaming failed — send everything as text to ensure user sees the result
362
- if (accumulatedContent.trim()) {
363
- const shortContent = truncateContent(accumulatedContent, 30, 4000);
364
- await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
365
- console.error(`[${ts()}] Failed to send content fallback: ${(err as Error).message}`)
366
- );
367
- }
368
- if (finalText.trim()) {
369
- await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
370
- console.error(`[${ts()}] Failed to send text fallback: ${(err as Error).message}`)
371
- );
372
- }
373
- } else {
374
- // Normal path: card streaming worked fine
375
- if (finalText.trim()) {
376
- await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
377
- console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
378
- );
379
- } else if (!cardId && accumulatedContent.trim()) {
380
- const shortContent = truncateContent(accumulatedContent, 30, 4000);
381
- await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
382
- console.error(`[${ts()}] Failed to send content text: ${(err as Error).message}`)
383
- );
384
- }
385
- }
386
-
387
- console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${chunkCount})`);
388
- }
389
-
390
- // ---------------------------------------------------------------------------
391
- // Session status query ( /status 命令使用)
392
- // ---------------------------------------------------------------------------
393
-
394
- export interface SessionStatus {
395
- sessionId: string;
396
- running: boolean;
397
- turnCount: number;
398
- lastContextTokens: number;
399
- startTime: number;
400
- model: string;
401
- effort: string;
402
- accumulatedLength: number;
403
- }
404
-
405
- export function getSessionStatus(chatId: string): SessionStatus | null {
406
- const info = sessionInfoMap.get(chatId);
407
- if (!info) return null;
408
-
409
- const active = chatSessionMap.get(chatId);
410
- return {
411
- sessionId: info.sessionId,
412
- running: active !== undefined && !active.stopped,
413
- turnCount: info.turnCount,
414
- lastContextTokens: info.lastContextTokens,
415
- startTime: info.startTime,
416
- model: anthropicConfigDisplay(info.model),
417
- effort: anthropicConfigDisplay(info.effort),
418
- accumulatedLength: active ? active.accumulatedContent.length + active.finalText.length : 0,
419
- };
420
- }
421
-
422
- /**
423
- * 获取所有已记录的会话状态列表(供 /sessions 命令使用)
424
- */
425
- export function getAllSessionsStatus(): Array<{
426
- chatId: string;
427
- sessionId: string;
428
- active: boolean;
429
- turnCount: number;
430
- startTime: number;
431
- model: string;
432
- effort: string;
433
- }> {
434
- const result: Array<{
435
- chatId: string;
436
- sessionId: string;
437
- active: boolean;
438
- turnCount: number;
439
- startTime: number;
440
- model: string;
441
- effort: string;
442
- }> = [];
443
- for (const [chatId, info] of sessionInfoMap) {
444
- const active = chatSessionMap.get(chatId);
445
- result.push({
446
- chatId,
447
- sessionId: info.sessionId,
448
- active: active !== undefined && !active.stopped,
449
- turnCount: info.turnCount,
450
- startTime: info.startTime,
451
- model: info.model,
452
- effort: info.effort,
453
- });
454
- }
455
- return result;
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import {
5
+ CLAUDE_EFFORT,
6
+ CLAUDE_MODEL,
7
+ SESSIONS_FILE,
8
+ addRecentDir,
9
+ anthropicConfigDisplay,
10
+ fileLog,
11
+ getDefaultCwd,
12
+ isSdkAnthropicDefault,
13
+ toolDisplayName,
14
+ ts,
15
+ } from "./config.ts";
16
+ import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
17
+ import {
18
+ createCardKitCard,
19
+ sendCardKitMessage,
20
+ updateCardKitCard,
21
+ } from "./cardkit.ts";
22
+ import { sendTextReply } from "./feishu-api.ts";
23
+ import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
24
+ import type { ToolAdapter } from "./adapters/adapter-interface.ts";
25
+ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
26
+ import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Shared state (imported by index.ts)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const processedMessages = new Set<string>();
33
+ export const MAX_PROCESSED = 5000;
34
+
35
+ export let sessionGen = 0;
36
+ export const chatSessionMap = new Map<string, {
37
+ gen: number;
38
+ close: () => void;
39
+ cardId: string | null;
40
+ stopped: boolean;
41
+ accumulatedContent: string;
42
+ finalText: string;
43
+ spinnerTimer: ReturnType<typeof setInterval> | null;
44
+ msgTimestamp: number;
45
+ sequence: number;
46
+ cardBusy: boolean;
47
+ }>();
48
+
49
+ export const sessionInfoMap = new Map<string, {
50
+ sessionId: string;
51
+ turnCount: number;
52
+ lastContextTokens: number;
53
+ startTime: number;
54
+ model: string;
55
+ effort: string;
56
+ tool: string;
57
+ }>();
58
+
59
+ export function resetState(): void {
60
+ for (const entry of chatSessionMap.values()) {
61
+ if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
62
+ try { entry.close(); } catch { /* ignore */ }
63
+ }
64
+ chatSessionMap.clear();
65
+ sessionInfoMap.clear();
66
+ processedMessages.clear();
67
+ console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions)`);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Adapter: 按 tool 创建并缓存
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const adapterCache = new Map<string, ToolAdapter>();
75
+
76
+ export function getAdapterForTool(tool: string): ToolAdapter {
77
+ const cached = adapterCache.get(tool);
78
+ if (cached) return cached;
79
+
80
+ let adapter: ToolAdapter;
81
+ if (tool === "cursor") {
82
+ adapter = createCursorAdapter();
83
+ } else {
84
+ adapter = createClaudeAdapter({
85
+ model: CLAUDE_MODEL,
86
+ effort: CLAUDE_EFFORT,
87
+ isDefault: isSdkAnthropicDefault,
88
+ });
89
+ }
90
+ adapterCache.set(tool, adapter);
91
+ return adapter;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Session tool persistence (.claude/sessions.json)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface SessionToolRecord {
99
+ tool: string;
100
+ createdAt: number;
101
+ }
102
+
103
+ async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
104
+ try {
105
+ const raw = await readFile(SESSIONS_FILE, "utf-8");
106
+ return JSON.parse(raw);
107
+ } catch {
108
+ return {};
109
+ }
110
+ }
111
+
112
+ async function saveSessionTools(data: Record<string, SessionToolRecord>): Promise<void> {
113
+ try {
114
+ await mkdir(dirname(SESSIONS_FILE), { recursive: true });
115
+ await writeFile(SESSIONS_FILE, JSON.stringify(data, null, 2), "utf-8");
116
+ } catch (err) {
117
+ console.error(`[${ts()}] Failed to save sessions.json: ${(err as Error).message}`);
118
+ fileLog.flush();
119
+ }
120
+ }
121
+
122
+ export async function saveSessionTool(sessionId: string, tool: string): Promise<void> {
123
+ const data = await loadSessionTools();
124
+ data[sessionId] = { tool, createdAt: Date.now() };
125
+ await saveSessionTools(data);
126
+ }
127
+
128
+ export async function getSessionTool(sessionId: string): Promise<string | null> {
129
+ const data = await loadSessionTools();
130
+ const record = data[sessionId];
131
+ return record?.tool ?? null;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ export interface AccumulatorState {
139
+ accumulatedContent: string;
140
+ finalText: string;
141
+ chunkCount: number;
142
+ }
143
+
144
+ export function accumulateBlockContent(
145
+ block: UnifiedBlock,
146
+ state: AccumulatorState,
147
+ ): void {
148
+ switch (block.type) {
149
+ case "thinking":
150
+ state.chunkCount++;
151
+ state.accumulatedContent += block.thinking;
152
+ break;
153
+ case "tool_use": {
154
+ const inputStr =
155
+ typeof block.input === "object"
156
+ ? JSON.stringify(block.input)
157
+ : String(block.input ?? "");
158
+ const shortInput =
159
+ inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
160
+ state.accumulatedContent +=
161
+ `\n\n${getToolEmoji(block.name)} **${block.name}**\n\`${shortInput}\`\n`;
162
+ break;
163
+ }
164
+ case "tool_result": {
165
+ const toolUseId = block.tool_use_id;
166
+ const resultContent = block.content;
167
+ let resultStr = "";
168
+ if (typeof resultContent === "string") {
169
+ resultStr = resultContent;
170
+ } else if (Array.isArray(resultContent)) {
171
+ resultStr = resultContent
172
+ .map((c: { type?: string; text?: string }) => c.text ?? "")
173
+ .join("");
174
+ } else if (resultContent) {
175
+ resultStr = JSON.stringify(resultContent);
176
+ }
177
+ const shortResult =
178
+ resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
179
+ const isError = block.is_error;
180
+ const icon = isError ? "❌" : "✅"; // ❌ : ✅
181
+ state.accumulatedContent +=
182
+ `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
183
+ break;
184
+ }
185
+ case "redacted_thinking":
186
+ state.accumulatedContent += "\n\n⚠️ 内容被安全过滤\n"; // ⚠️
187
+ break;
188
+ case "search_result":
189
+ state.accumulatedContent +=
190
+ `\n\n🔍 联网搜索: **${block.query}**\n`; // 🔍
191
+ break;
192
+ case "text":
193
+ state.finalText += block.text;
194
+ break;
195
+ case "compact_boundary": {
196
+ const triggerLabel = block.trigger === "manual" ? "手动" : "自动"; // 手动 / 自动
197
+ state.accumulatedContent +=
198
+ `\n\n🔄 上下文压缩(${triggerLabel}): **${block.pre_tokens}** **${block.post_tokens}** tokens\n`; // 🔄 / →
199
+ break;
200
+ }
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Claude session management
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export async function initClaudeSession(tool: string): Promise<string> {
209
+ const cwd = await getDefaultCwd();
210
+ const adapter = getAdapterForTool(tool);
211
+ console.log(
212
+ `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}, cwd=${cwd})`
213
+ );
214
+
215
+ const result = await adapter.createSession(cwd);
216
+ const sessionId = result.sessionId;
217
+ console.log(`[${ts()}] → sessionId: ${sessionId}`);
218
+
219
+ await saveSessionTool(sessionId, tool);
220
+
221
+ await addRecentDir(cwd);
222
+
223
+ return sessionId;
224
+ }
225
+
226
+ export async function resumeAndPrompt(
227
+ sessionId: string,
228
+ userText: string,
229
+ token: string,
230
+ chatId: string,
231
+ msgTimestamp: number,
232
+ tool: string,
233
+ ): Promise<void> {
234
+ const adapter = getAdapterForTool(tool);
235
+ const info = await adapter.getSessionInfo(sessionId);
236
+ const cwd = info?.cwd ?? (await getDefaultCwd());
237
+ console.log(
238
+ `[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}, cwd=${cwd})`
239
+ );
240
+
241
+ const controller = new AbortController();
242
+
243
+ chatSessionMap.set(chatId, {
244
+ gen: ++sessionGen,
245
+ close: () => controller.abort(),
246
+ cardId: null,
247
+ stopped: false,
248
+ accumulatedContent: "",
249
+ finalText: "",
250
+ spinnerTimer: null,
251
+ msgTimestamp,
252
+ sequence: 0,
253
+ cardBusy: false,
254
+ });
255
+ const myGen = sessionGen;
256
+
257
+ const now = Date.now();
258
+ const existingInfo = sessionInfoMap.get(chatId);
259
+ sessionInfoMap.set(chatId, {
260
+ sessionId,
261
+ turnCount: (existingInfo?.turnCount ?? 0) + 1,
262
+ lastContextTokens: existingInfo?.lastContextTokens ?? 0,
263
+ startTime: now,
264
+ model: anthropicConfigDisplay(CLAUDE_MODEL),
265
+ effort: anthropicConfigDisplay(CLAUDE_EFFORT),
266
+ tool,
267
+ });
268
+
269
+ let cardId: string | null = null;
270
+ cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
271
+ console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
272
+ fileLog.flush();
273
+ sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
274
+ return null;
275
+ });
276
+ if (cardId) {
277
+ const cEntry = chatSessionMap.get(chatId);
278
+ if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
279
+ const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
280
+ console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
281
+ fileLog.flush();
282
+ return false;
283
+ });
284
+ if (!sendOk) {
285
+ sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
286
+ cardId = null;
287
+ if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
288
+ }
289
+ }
290
+
291
+ const state: AccumulatorState = {
292
+ accumulatedContent: "",
293
+ finalText: "",
294
+ chunkCount: 0,
295
+ };
296
+
297
+ let cardCreatedAt = Date.now();
298
+ const CARD_ROTATE_MS = 9 * 60 * 1000;
299
+
300
+ let dotCount = 0;
301
+ let lastSentContent = "";
302
+ let streamErrorNotified = false;
303
+ let healthLogTicks = 0;
304
+ const sendInterval = cardId ? setInterval(async () => {
305
+ const cEntry = chatSessionMap.get(chatId);
306
+ if (!cEntry || cEntry.stopped || cEntry.cardBusy) return;
307
+ if (cEntry.cardId !== cardId) return;
308
+
309
+ if (Date.now() - cardCreatedAt > CARD_ROTATE_MS) {
310
+ cEntry.cardBusy = true;
311
+ try {
312
+ const oldSeqBase = cEntry.sequence;
313
+ const oldDisplay = truncateContent(state.accumulatedContent + state.finalText) || "处理中...";
314
+ const oldCard = buildProgressCard(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
315
+ await updateCardKitCard(token, cardId!, oldCard, oldSeqBase + 1).catch(() => {});
316
+ const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
317
+ if (!newCardId) throw new Error("createCardKitCard returned empty");
318
+ await sendCardKitMessage(token, chatId, newCardId);
319
+ cardId = newCardId;
320
+ cEntry.cardId = newCardId;
321
+ cEntry.sequence = 1;
322
+ cardCreatedAt = Date.now();
323
+ lastSentContent = "";
324
+ streamErrorNotified = false;
325
+ console.log(`[${ts()}] [CARDIKT] rotated: old=${oldSeqBase} new=${newCardId} (9min timeout)`);
326
+ } catch (err) {
327
+ console.error(`[${ts()}] [CARDIKT] rotation FAIL: ${(err as Error).message}`);
328
+ } finally {
329
+ cEntry.cardBusy = false;
330
+ }
331
+ return;
332
+ }
333
+
334
+ dotCount = (dotCount % 9) + 1;
335
+ const content = truncateContent(state.accumulatedContent + state.finalText + "\n" + "。".repeat(dotCount));
336
+ if (content === lastSentContent) return;
337
+
338
+ lastSentContent = content;
339
+ cEntry.cardBusy = true;
340
+ const mySeq = cEntry.sequence + 1;
341
+ try {
342
+ const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
343
+ await updateCardKitCard(token, cardId!, card, mySeq);
344
+ cEntry.sequence = mySeq;
345
+ cEntry.accumulatedContent = state.accumulatedContent;
346
+ streamErrorNotified = false;
347
+ healthLogTicks++;
348
+ if (healthLogTicks % 10 === 0) {
349
+ console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} content=${state.accumulatedContent.length}chars text=${state.finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
350
+ }
351
+ } catch (err) {
352
+ console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
353
+ if (!streamErrorNotified) {
354
+ streamErrorNotified = true;
355
+ sendTextReply(token, chatId, "⚠️ 卡片更新失败,结果将以文本形式发送。").catch(() => {});
356
+ }
357
+ } finally {
358
+ cEntry.cardBusy = false;
359
+ }
360
+ }, 3000) : null;
361
+ if (sendInterval) {
362
+ const entry = chatSessionMap.get(chatId);
363
+ if (entry) entry.spinnerTimer = sendInterval;
364
+ }
365
+
366
+ try {
367
+ for await (const unifiedMsg of adapter.prompt(sessionId, userText, cwd, controller.signal)) {
368
+ for (const block of unifiedMsg.blocks) {
369
+ accumulateBlockContent(block, state);
370
+
371
+ // 更新持久化上下文 token 数(compact_boundary 事件)
372
+ if (block.type === "compact_boundary" && block.post_tokens) {
373
+ const info = sessionInfoMap.get(chatId);
374
+ if (info) { info.lastContextTokens = block.post_tokens; }
375
+ }
376
+ }
377
+ }
378
+ } catch (streamErr) {
379
+ console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
380
+ } finally {
381
+ if (sendInterval) clearInterval(sendInterval);
382
+ }
383
+
384
+ const cEntry = chatSessionMap.get(chatId);
385
+ if (!cEntry || cEntry.gen !== myGen) return;
386
+ const wasStopped = cEntry.stopped;
387
+ chatSessionMap.delete(chatId);
388
+
389
+ if (cardId && state.accumulatedContent) {
390
+ while (cEntry.cardBusy) {
391
+ await new Promise(r => setTimeout(r, 20));
392
+ }
393
+ const nextSeq = cEntry.sequence + 1;
394
+ if (wasStopped) {
395
+ const stopCard = buildProgressCard(state.accumulatedContent || "已停止", { showStop: false, headerTitle: "已停止", headerTemplate: "red" });
396
+ await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {
397
+ console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
398
+ fileLog.flush();
399
+ });
400
+ } else {
401
+ const doneCard = buildProgressCard(state.accumulatedContent, { showStop: false, headerTitle: "完成" });
402
+ await updateCardKitCard(token, cardId, doneCard, nextSeq).catch((err) => {
403
+ console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
404
+ fileLog.flush();
405
+ sendTextReply(token, chatId, "⚠️ 卡片最终更新失败。").catch(() => {});
406
+ });
407
+ }
408
+ }
409
+
410
+ if (wasStopped) {
411
+ if (state.finalText.trim()) {
412
+ await sendTextReply(token, chatId, state.finalText.trim()).catch((err) =>
413
+ console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
414
+ );
415
+ }
416
+ console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
417
+ return;
418
+ }
419
+
420
+ if (streamErrorNotified) {
421
+ if (state.accumulatedContent.trim()) {
422
+ const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
423
+ await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
424
+ console.error(`[${ts()}] Failed to send content fallback: ${(err as Error).message}`)
425
+ );
426
+ }
427
+ if (state.finalText.trim()) {
428
+ await sendTextReply(token, chatId, state.finalText.trim()).catch((err) =>
429
+ console.error(`[${ts()}] Failed to send text fallback: ${(err as Error).message}`)
430
+ );
431
+ }
432
+ } else {
433
+ if (state.finalText.trim()) {
434
+ await sendTextReply(token, chatId, state.finalText.trim()).catch((err) =>
435
+ console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
436
+ );
437
+ } else if (!cardId && state.accumulatedContent.trim()) {
438
+ const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
439
+ await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
440
+ console.error(`[${ts()}] Failed to send content text: ${(err as Error).message}`)
441
+ );
442
+ }
443
+ }
444
+
445
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Session status query (供 /status 命令使用)
450
+ // ---------------------------------------------------------------------------
451
+
452
+ export interface SessionStatus {
453
+ sessionId: string;
454
+ running: boolean;
455
+ turnCount: number;
456
+ lastContextTokens: number;
457
+ startTime: number;
458
+ model: string;
459
+ effort: string;
460
+ accumulatedLength: number;
461
+ }
462
+
463
+ export function getSessionStatus(chatId: string): SessionStatus | null {
464
+ const info = sessionInfoMap.get(chatId);
465
+ if (!info) return null;
466
+
467
+ const active = chatSessionMap.get(chatId);
468
+ return {
469
+ sessionId: info.sessionId,
470
+ running: active !== undefined && !active.stopped,
471
+ turnCount: info.turnCount,
472
+ lastContextTokens: info.lastContextTokens,
473
+ startTime: info.startTime,
474
+ model: anthropicConfigDisplay(info.model),
475
+ effort: anthropicConfigDisplay(info.effort),
476
+ accumulatedLength: active ? active.accumulatedContent.length + active.finalText.length : 0,
477
+ };
478
+ }
479
+
480
+ export function getAllSessionsStatus(): Array<{
481
+ chatId: string;
482
+ sessionId: string;
483
+ active: boolean;
484
+ turnCount: number;
485
+ startTime: number;
486
+ model: string;
487
+ effort: string;
488
+ tool: string;
489
+ }> {
490
+ const result: Array<{
491
+ chatId: string;
492
+ sessionId: string;
493
+ active: boolean;
494
+ turnCount: number;
495
+ startTime: number;
496
+ model: string;
497
+ effort: string;
498
+ tool: string;
499
+ }> = [];
500
+ for (const [chatId, info] of sessionInfoMap) {
501
+ const active = chatSessionMap.get(chatId);
502
+ result.push({
503
+ chatId,
504
+ sessionId: info.sessionId,
505
+ active: active !== undefined && !active.stopped,
506
+ turnCount: info.turnCount,
507
+ startTime: info.startTime,
508
+ model: info.model,
509
+ effort: info.effort,
510
+ tool: info.tool,
511
+ });
512
+ }
513
+ return result;
456
514
  }