chatccc 0.1.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/src/session.ts ADDED
@@ -0,0 +1,429 @@
1
+ import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk";
2
+
3
+ import { CLAUDE_EFFORT, CLAUDE_MODEL, fileLog, ts } from "./config.ts";
4
+ import { buildThinkingCardV2, getToolEmoji, truncateContent } from "./cards.ts";
5
+ import {
6
+ createCardKitCard,
7
+ sendCardKitMessage,
8
+ setCardKitSettings,
9
+ streamCardKitElement,
10
+ updateCardKitCard,
11
+ } from "./cardkit.ts";
12
+ import { sendTextReply } from "./feishu-api.ts";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Shared state (imported by index.ts)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const processedMessages = new Set<string>();
19
+ export const MAX_PROCESSED = 5000;
20
+
21
+ export let sessionGen = 0;
22
+ export const chatSessionMap = new Map<string, {
23
+ gen: number;
24
+ close: () => void;
25
+ cardId: string | null;
26
+ stopped: boolean;
27
+ accumulatedThinking: string;
28
+ finalText: string;
29
+ spinnerTimer: ReturnType<typeof setInterval> | null;
30
+ msgTimestamp: number;
31
+ sequence: number;
32
+ cardBusy: boolean;
33
+ }>();
34
+
35
+ // 持久化会话信息,流式结束后不清除,供 /status 查询
36
+ export const sessionInfoMap = new Map<string, {
37
+ sessionId: string;
38
+ turnCount: number;
39
+ lastContextTokens: number;
40
+ startTime: number;
41
+ model: string;
42
+ effort: string;
43
+ }>();
44
+
45
+ export function resetState(): void {
46
+ for (const entry of chatSessionMap.values()) {
47
+ if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
48
+ try { entry.close(); } catch { /* ignore */ }
49
+ }
50
+ chatSessionMap.clear();
51
+ sessionInfoMap.clear();
52
+ processedMessages.clear();
53
+ console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions)`);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Claude session management
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export async function initClaudeSession(): Promise<string> {
61
+ console.log(`[${ts()}] [STEP 1/5] Creating Claude session via SDK (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT})`);
62
+
63
+ const session = unstable_v2_createSession({
64
+ model: CLAUDE_MODEL,
65
+ effort: CLAUDE_EFFORT,
66
+ permissionMode: "bypassPermissions",
67
+ allowDangerouslySkipPermissions: true,
68
+ autoCompactEnabled: true,
69
+ } as any);
70
+
71
+ await session.send("ok");
72
+
73
+ const stream = session.stream();
74
+
75
+ const first = await stream.next();
76
+ if (first.done || !(first.value as { session_id?: string }).session_id) {
77
+ session.close();
78
+ throw new Error("No session ID in Claude init event");
79
+ }
80
+
81
+ const initMsg = first.value as { session_id: string };
82
+ const sessionId = initMsg.session_id;
83
+ console.log(`[${ts()}] → sessionId: ${sessionId}`);
84
+
85
+ (async () => {
86
+ try {
87
+ for await (const _msg of stream) {
88
+ // 静默消费,不做额外处理
89
+ }
90
+ } catch {
91
+ // stream 异常不阻塞主流程
92
+ } finally {
93
+ session.close();
94
+ }
95
+ })();
96
+
97
+ return sessionId;
98
+ }
99
+
100
+ export async function resumeAndPrompt(
101
+ sessionId: string,
102
+ userText: string,
103
+ token: string,
104
+ chatId: string,
105
+ msgTimestamp: number
106
+ ): Promise<void> {
107
+ console.log(`[${ts()}] Resuming Claude session: ${sessionId} (model=${CLAUDE_MODEL}, effort=${CLAUDE_EFFORT})`);
108
+
109
+ const session = unstable_v2_resumeSession(sessionId, {
110
+ model: CLAUDE_MODEL,
111
+ effort: CLAUDE_EFFORT,
112
+ permissionMode: "bypassPermissions",
113
+ allowDangerouslySkipPermissions: true,
114
+ autoCompactEnabled: true,
115
+ } as any);
116
+
117
+ let cardId: string | null = null;
118
+ let streamingEnabled = false;
119
+ chatSessionMap.set(chatId, {
120
+ gen: ++sessionGen,
121
+ close: () => session.close(),
122
+ cardId: null,
123
+ stopped: false,
124
+ accumulatedThinking: "",
125
+ finalText: "",
126
+ spinnerTimer: null,
127
+ msgTimestamp,
128
+ sequence: 0,
129
+ cardBusy: false,
130
+ });
131
+ const myGen = sessionGen;
132
+
133
+ // 更新持久化会话信息
134
+ const now = Date.now();
135
+ const existingInfo = sessionInfoMap.get(chatId);
136
+ sessionInfoMap.set(chatId, {
137
+ sessionId,
138
+ turnCount: (existingInfo?.turnCount ?? 0) + 1,
139
+ lastContextTokens: existingInfo?.lastContextTokens ?? 0,
140
+ startTime: now,
141
+ model: CLAUDE_MODEL,
142
+ effort: CLAUDE_EFFORT,
143
+ });
144
+
145
+ await session.send(userText);
146
+
147
+ cardId = await createCardKitCard(token, buildThinkingCardV2("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
148
+ console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
149
+ fileLog.flush();
150
+ sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
151
+ return null;
152
+ });
153
+ if (cardId) {
154
+ const cEntry = chatSessionMap.get(chatId);
155
+ if (cEntry) { cEntry.cardId = cardId; }
156
+ const settingsOk = await setCardKitSettings(token, cardId, { streaming_mode: true }, 1).catch((err) => {
157
+ console.error(`[${ts()}] [CARDIKT] enableStreaming FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
158
+ fileLog.flush();
159
+ return false;
160
+ });
161
+ const settingsSucceeded = settingsOk !== false;
162
+ if (settingsSucceeded && cEntry) { cEntry.sequence = 1; }
163
+ const sendOk = settingsSucceeded ? await sendCardKitMessage(token, chatId, cardId).catch((err) => {
164
+ console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
165
+ fileLog.flush();
166
+ return false;
167
+ }) : false;
168
+ if (!sendOk) {
169
+ sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
170
+ cardId = null;
171
+ if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
172
+ } else {
173
+ streamingEnabled = true;
174
+ }
175
+ }
176
+
177
+ const stream = session.stream();
178
+
179
+ let thinkingCount = 0;
180
+ let accumulatedThinking = "";
181
+ let finalText = "";
182
+
183
+ let cardCreatedAt = Date.now();
184
+ let cardStartThinkingLen = 0;
185
+ let cardStartTextLen = 0;
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
+ await setCardKitSettings(token, oldCardId!, { streaming_mode: false }, oldSeqBase + 1).catch(() => {});
205
+ // 2. 用当前累积内容更新旧卡片为静态版本
206
+ const oldDisplay = truncateContent(accumulatedThinking + finalText) || "处理中...";
207
+ const oldCard = buildThinkingCardV2(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
208
+ await updateCardKitCard(token, oldCardId!, oldCard, oldSeqBase + 2).catch(() => {});
209
+ // 3. 创建新卡片
210
+ const newCardId = await createCardKitCard(token, buildThinkingCardV2("", { showStop: true, headerTitle: "生成中..." }));
211
+ if (!newCardId) throw new Error("createCardKitCard returned empty");
212
+ // 4. 开启流式模式并发送
213
+ await setCardKitSettings(token, newCardId, { streaming_mode: true }, 1);
214
+ await sendCardKitMessage(token, chatId, newCardId);
215
+ // 5. 切换到新卡片
216
+ cardId = newCardId;
217
+ cEntry.cardId = newCardId;
218
+ cEntry.sequence = 1;
219
+ cardCreatedAt = Date.now();
220
+ cardStartThinkingLen = accumulatedThinking.length;
221
+ cardStartTextLen = finalText.length;
222
+ lastSentContent = "";
223
+ streamErrorNotified = false;
224
+ console.log(`[${ts()}] [CARDIKT] rotated: old=${oldCardId} new=${newCardId} (9min timeout)`);
225
+ } catch (err) {
226
+ console.error(`[${ts()}] [CARDIKT] rotation FAIL: ${(err as Error).message}`);
227
+ } finally {
228
+ cEntry.cardBusy = false;
229
+ }
230
+ return;
231
+ }
232
+
233
+ dotCount = (dotCount % 9) + 1;
234
+ const currentContent = accumulatedThinking.slice(cardStartThinkingLen) + finalText.slice(cardStartTextLen);
235
+ const content = truncateContent(currentContent + "\n" + ".".repeat(dotCount));
236
+ if (content === lastSentContent) return;
237
+
238
+ lastSentContent = content;
239
+ cEntry.cardBusy = true;
240
+ const mySeq = cEntry.sequence + 1;
241
+ try {
242
+ await streamCardKitElement(token, cardId!, "main_content", content, mySeq);
243
+ cEntry.sequence = mySeq;
244
+ cEntry.accumulatedThinking = accumulatedThinking;
245
+ streamErrorNotified = false;
246
+ healthLogTicks++;
247
+ if (healthLogTicks % 10 === 0) {
248
+ console.log(`[${ts()}] [CARDIKT] stream health: seq=${mySeq} thinking=${accumulatedThinking.length}chars text=${finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
249
+ }
250
+ } catch (err) {
251
+ console.error(`[${ts()}] CardKit stream error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
252
+ if (!streamErrorNotified) {
253
+ streamErrorNotified = true;
254
+ sendTextReply(token, chatId, "⚠️ 卡片流式更新失败,结果将以文本形式发送。").catch(() => {});
255
+ }
256
+ } finally {
257
+ cEntry.cardBusy = false;
258
+ }
259
+ }, 3000) : null;
260
+ if (sendInterval) {
261
+ const entry = chatSessionMap.get(chatId);
262
+ if (entry) entry.spinnerTimer = sendInterval;
263
+ }
264
+
265
+ try {
266
+ for await (const msg of stream) {
267
+ const sdkMsg = msg as {
268
+ type?: string;
269
+ message?: { content?: Array<{ type: string; thinking?: string; text?: string; name?: string; input?: unknown; tool_use_id?: string; content?: unknown; is_error?: boolean }> };
270
+ };
271
+ if ((sdkMsg.type === "assistant" || sdkMsg.type === "user") && sdkMsg.message?.content) {
272
+ for (const block of sdkMsg.message.content) {
273
+
274
+ if (block.type === "thinking" && block.thinking) {
275
+ thinkingCount++;
276
+ accumulatedThinking += block.thinking;
277
+ } else if (block.type === "tool_use") {
278
+ const toolName = (block as { name?: string }).name ?? "unknown";
279
+ const toolInput = (block as { input?: unknown }).input;
280
+ const inputStr = typeof toolInput === "object" ? JSON.stringify(toolInput) : String(toolInput ?? "");
281
+ const shortInput = inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
282
+ accumulatedThinking += `\n\n${getToolEmoji(toolName)} **${toolName}**\n\`${shortInput}\`\n`;
283
+ } else if (block.type === "tool_result") {
284
+ const toolUseId = (block as { tool_use_id?: string }).tool_use_id ?? "";
285
+ const resultContent = (block as { content?: unknown }).content;
286
+ let resultStr = "";
287
+ if (typeof resultContent === "string") {
288
+ resultStr = resultContent;
289
+ } else if (Array.isArray(resultContent)) {
290
+ resultStr = resultContent.map((c: { type?: string; text?: string }) => c.text ?? "").join("");
291
+ } else if (resultContent) {
292
+ resultStr = JSON.stringify(resultContent);
293
+ }
294
+ const shortResult = resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
295
+ const isError = (block as { is_error?: boolean }).is_error;
296
+ const icon = isError ? "❌" : "✅";
297
+ accumulatedThinking += `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
298
+ } else if (block.type === "redacted_thinking") {
299
+ accumulatedThinking += "\n\n⚠️ 思考内容被安全过滤\n";
300
+ } else if (block.type === "search_result") {
301
+ const searchQuery = (block as { query?: string }).query ?? "";
302
+ accumulatedThinking += `\n\n🔍 联网搜索: **${searchQuery}**\n`;
303
+ } else if (block.type === "text" && block.text) {
304
+ finalText += block.text;
305
+ const entry = chatSessionMap.get(chatId);
306
+ if (entry) entry.finalText = finalText;
307
+ }
308
+ }
309
+ } else if (sdkMsg.type === "system" && (sdkMsg as { subtype?: string }).subtype === "compact_boundary") {
310
+ const compactMeta = (sdkMsg as { compact_metadata?: { trigger?: string; pre_tokens?: number; post_tokens?: number } }).compact_metadata;
311
+ if (compactMeta) {
312
+ const triggerLabel = compactMeta.trigger === "manual" ? "手动" : "自动";
313
+ accumulatedThinking += `\n\n🔄 上下文压缩(${triggerLabel}): **${compactMeta.pre_tokens}** → **${compactMeta.post_tokens}** tokens\n`;
314
+ // 更新持久化上下文 token 数
315
+ if (compactMeta.post_tokens) {
316
+ const info = sessionInfoMap.get(chatId);
317
+ if (info) { info.lastContextTokens = compactMeta.post_tokens; }
318
+ }
319
+ }
320
+ }
321
+ }
322
+ } catch (streamErr) {
323
+ console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
324
+ } finally {
325
+ if (sendInterval) clearInterval(sendInterval);
326
+ session.close();
327
+ }
328
+
329
+ const cEntry = chatSessionMap.get(chatId);
330
+ if (!cEntry || cEntry.gen !== myGen) return;
331
+ const wasStopped = cEntry.stopped;
332
+ chatSessionMap.delete(chatId);
333
+
334
+ if (cardId && accumulatedThinking) {
335
+ while (cEntry.cardBusy) {
336
+ await new Promise(r => setTimeout(r, 20));
337
+ }
338
+ let nextSeq = cEntry.sequence + 1;
339
+ if (streamingEnabled) {
340
+ await setCardKitSettings(token, cardId, { streaming_mode: false }, nextSeq++).catch(() => {});
341
+ }
342
+ if (wasStopped) {
343
+ const stopCard = buildThinkingCardV2(accumulatedThinking || "已停止", { showStop: false, headerTitle: "已停止", headerTemplate: "red" });
344
+ await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {
345
+ console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
346
+ fileLog.flush();
347
+ });
348
+ } else {
349
+ const doneCard = buildThinkingCardV2(accumulatedThinking, { showStop: false, headerTitle: "完成" });
350
+ await updateCardKitCard(token, cardId, doneCard, nextSeq).catch((err) => {
351
+ console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
352
+ fileLog.flush();
353
+ sendTextReply(token, chatId, "⚠️ 卡片最终更新失败。").catch(() => {});
354
+ });
355
+ }
356
+ }
357
+
358
+ // Text fallback: if CardKit streaming broke, always send full result as text
359
+ if (wasStopped) {
360
+ if (finalText.trim()) {
361
+ await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
362
+ console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
363
+ );
364
+ }
365
+ console.log(`[${ts()}] Session ${sessionId} stopped by user (thinking chunks: ${thinkingCount})`);
366
+ return;
367
+ }
368
+
369
+ if (streamErrorNotified) {
370
+ // CardKit streaming failed — send everything as text to ensure user sees the result
371
+ if (accumulatedThinking.trim()) {
372
+ const shortThinking = truncateContent(accumulatedThinking, 30, 4000);
373
+ await sendTextReply(token, chatId, `[思考过程]\n${shortThinking}`).catch((err) =>
374
+ console.error(`[${ts()}] Failed to send thinking fallback: ${(err as Error).message}`)
375
+ );
376
+ }
377
+ if (finalText.trim()) {
378
+ await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
379
+ console.error(`[${ts()}] Failed to send text fallback: ${(err as Error).message}`)
380
+ );
381
+ }
382
+ } else {
383
+ // Normal path: card streaming worked fine
384
+ if (finalText.trim()) {
385
+ await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
386
+ console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
387
+ );
388
+ } else if (!cardId && accumulatedThinking.trim()) {
389
+ const shortThinking = truncateContent(accumulatedThinking, 30, 4000);
390
+ await sendTextReply(token, chatId, `[思考过程]\n${shortThinking}`).catch((err) =>
391
+ console.error(`[${ts()}] Failed to send thinking text: ${(err as Error).message}`)
392
+ );
393
+ }
394
+ }
395
+
396
+ console.log(`[${ts()}] Session ${sessionId} stream complete (thinking chunks: ${thinkingCount})`);
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Session status query (供 /status 命令使用)
401
+ // ---------------------------------------------------------------------------
402
+
403
+ export interface SessionStatus {
404
+ sessionId: string;
405
+ running: boolean;
406
+ turnCount: number;
407
+ lastContextTokens: number;
408
+ startTime: number;
409
+ model: string;
410
+ effort: string;
411
+ accumulatedLength: number;
412
+ }
413
+
414
+ export function getSessionStatus(chatId: string): SessionStatus | null {
415
+ const info = sessionInfoMap.get(chatId);
416
+ if (!info) return null;
417
+
418
+ const active = chatSessionMap.get(chatId);
419
+ return {
420
+ sessionId: info.sessionId,
421
+ running: active !== undefined && !active.stopped,
422
+ turnCount: info.turnCount,
423
+ lastContextTokens: info.lastContextTokens,
424
+ startTime: info.startTime,
425
+ model: info.model,
426
+ effort: info.effort,
427
+ accumulatedLength: active ? active.accumulatedThinking.length + active.finalText.length : 0,
428
+ };
429
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ createWriteStream,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { WebSocketServer, WebSocket } from "ws";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // 杀死进程
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export function killByPid(pid: string | number): void {
18
+ const pidNum = typeof pid === "string" ? parseInt(pid, 10) : pid;
19
+ try {
20
+ process.kill(pidNum, "SIGTERM");
21
+ } catch {
22
+ // 不存在,忽略
23
+ }
24
+ try {
25
+ execSync(`taskkill /PID ${pidNum} /F /T`, { encoding: "utf8", stdio: "pipe" });
26
+ } catch {
27
+ // taskkill 失败通常意味着进程已不在
28
+ }
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // 清理指定端口上的旧进程 + 杀所有本项目的 node/tsx 残留进程
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export function killAllProjectProcesses(port?: number): void {
36
+ // PowerShell 找出所有跑本项目脚本的 node/tsx 进程
37
+ try {
38
+ const psCmd =
39
+ `Get-CimInstance Win32_Process -Filter 'name=''node.exe'' or name=''tsx.exe''' | ` +
40
+ `Where-Object { $_.CommandLine -like '*ChatCCC*' -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid} } | ` +
41
+ `Select-Object -ExpandProperty ProcessId`;
42
+ const out = execSync(`powershell -NoProfile -Command "${psCmd}"`, {
43
+ encoding: "utf8",
44
+ timeout: 10000,
45
+ });
46
+ for (const pid of out.trim().split(/\s+/)) {
47
+ if (pid && pid !== String(process.pid)) {
48
+ console.log(`[KILL] Killing project process PID ${pid}...`);
49
+ killByPid(pid);
50
+ }
51
+ }
52
+ } catch {
53
+ // 回退到 wmic
54
+ try {
55
+ const out = execSync(
56
+ 'wmic process where "name=\'node.exe\' or name=\'tsx.exe\'" get processid,commandline /format:csv',
57
+ { encoding: "utf8", timeout: 5000 }
58
+ );
59
+ const lines = out.trim().split("\n");
60
+ for (const line of lines) {
61
+ if (!line.includes("ChatCCC") || line.includes(process.pid!.toString()) || line.includes(process.ppid!.toString())) continue;
62
+ const fields = line.split(",");
63
+ const pid = fields[1]?.trim();
64
+ if (pid && /^\d+$/.test(pid)) {
65
+ console.log(`[KILL] Killing project process PID ${pid}...`);
66
+ killByPid(pid);
67
+ }
68
+ }
69
+ } catch {
70
+ // 都不行,不阻塞启动
71
+ }
72
+ }
73
+
74
+ // 端口补刀
75
+ if (port !== undefined) {
76
+ try {
77
+ const portOut = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8" });
78
+ for (const line of portOut.trim().split("\n")) {
79
+ const m = line.match(/(\d+)\s*$/);
80
+ if (m && m[1] !== process.pid!.toString() && m[1] !== process.ppid!.toString()) {
81
+ console.log(`[KILL] Killing process on port ${port} PID ${m[1]}...`);
82
+ killByPid(m[1]);
83
+ }
84
+ }
85
+ } catch {
86
+ // 端口无人占用
87
+ }
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // 单实例保证:PID 文件互斥 + 激进杀残余进程
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export function cleanupPidFile(pidFile: string): void {
96
+ try {
97
+ if (existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() === String(process.pid)) {
98
+ unlinkSync(pidFile);
99
+ }
100
+ } catch { /* ok */ }
101
+ }
102
+
103
+ export function ensureSingleInstance(pidFile: string, port?: number): void {
104
+ if (existsSync(pidFile)) {
105
+ const oldPid = readFileSync(pidFile, "utf8").trim();
106
+ if (oldPid && oldPid !== String(process.pid)) {
107
+ console.log(`[INSTANCE] Killing old PID from file: ${oldPid}...`);
108
+ killByPid(oldPid);
109
+ }
110
+ }
111
+
112
+ killAllProjectProcesses(port);
113
+
114
+ mkdirSync(join(pidFile, ".."), { recursive: true });
115
+ writeFileSync(pidFile, String(process.pid));
116
+ console.log(`[INSTANCE] Registered PID ${process.pid}`);
117
+
118
+ // 进程退出时自动清理 PID 文件;SIGINT/SIGTERM 由各 main() 自行接管
119
+ process.on("exit", () => cleanupPidFile(pidFile));
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // 文件日志:同时输出到控制台和日志文件
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
127
+ mkdirSync(logDir, { recursive: true });
128
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
129
+ const logPath = join(logDir, `${prefix}-${ts}.log`);
130
+ const logStream = createWriteStream(logPath, { flags: "a" });
131
+ const origConsoleLog = console.log.bind(console);
132
+ const origConsoleError = console.error.bind(console);
133
+ let pending = false;
134
+ const writeLine = (level: string, args: unknown[]) => {
135
+ const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
136
+ pending = true;
137
+ logStream.write(`[${new Date().toISOString()}] [${level}] ${line}\n`, () => { pending = false; });
138
+ };
139
+ console.log = (...args: unknown[]) => {
140
+ writeLine("LOG", args);
141
+ origConsoleLog(...args);
142
+ };
143
+ console.error = (...args: unknown[]) => {
144
+ writeLine("ERR", args);
145
+ origConsoleError(...args);
146
+ };
147
+ const flush = () => {
148
+ if (pending) logStream.end();
149
+ };
150
+ origConsoleLog(`Log file: ${logPath}`);
151
+ return { logPath, flush };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // 本地 WebSocket 中继服务器(同一端口、多客户端广播)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export function createRelayServer(port: number): {
159
+ server: WebSocketServer;
160
+ broadcast: (data: unknown) => void;
161
+ } {
162
+ const clients = new Set<WebSocket>();
163
+ const server = new WebSocketServer({ host: "127.0.0.1", port });
164
+
165
+ server.on("connection", (ws) => {
166
+ console.log(`[RELAY] Client connected (total: ${clients.size + 1})`);
167
+ clients.add(ws);
168
+ ws.on("close", () => {
169
+ clients.delete(ws);
170
+ console.log(`[RELAY] Client disconnected (total: ${clients.size})`);
171
+ });
172
+ ws.on("error", () => { clients.delete(ws); });
173
+ });
174
+
175
+ console.log(`[RELAY] Local relay listening on ws://127.0.0.1:${port}`);
176
+
177
+ const broadcast = (data: unknown): void => {
178
+ const json = typeof data === "string" ? data : JSON.stringify(data);
179
+ for (const ws of clients) {
180
+ if (ws.readyState === WebSocket.OPEN) ws.send(json);
181
+ }
182
+ };
183
+
184
+ return { server, broadcast };
185
+ }