chatccc 0.2.115 → 0.2.117

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.115",
3
+ "version": "0.2.117",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -458,6 +458,12 @@ class ClaudeAdapter implements ToolAdapter {
458
458
 
459
459
  const normalized = normalizeSdkMessage(raw);
460
460
  if (normalized) yield normalized;
461
+
462
+ // result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
463
+ if (raw.type === "result") {
464
+ void killProcessTree(proc.pid);
465
+ break;
466
+ }
461
467
  }
462
468
  } finally {
463
469
  signal?.removeEventListener("abort", onAbort);
@@ -394,6 +394,12 @@ class CursorAdapter implements ToolAdapter {
394
394
  }
395
395
  const normalized = normalizeCursorMessage(raw);
396
396
  if (normalized) yield normalized;
397
+
398
+ // result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
399
+ if (raw.type === "result") {
400
+ void killProcessTree(proc.pid);
401
+ break;
402
+ }
397
403
  }
398
404
  } finally {
399
405
  signal?.removeEventListener("abort", onAbort);
@@ -0,0 +1,141 @@
1
+ // =============================================================================
2
+ // resource-monitor.ts — CLI 进程资源监控(CPU + 内存)
3
+ // =============================================================================
4
+ // 对所有 chatccc 启动的 CLI 进程持续监控 CPU 和内存占用。
5
+ // 若连续 3 分钟两项指标均无变化,判定为僵死,发出 "stuck" 事件。
6
+ // =============================================================================
7
+
8
+ import { exec } from "node:child_process";
9
+ import { EventEmitter } from "node:events";
10
+
11
+ const CHECK_INTERVAL_MS = 30_000; // 30 秒检查一次
12
+ const STUCK_THRESHOLD = 6; // 连续 6 次无变化 = 3 分钟
13
+
14
+ interface ProcessSnapshot {
15
+ cpu: number;
16
+ memory: number;
17
+ unchangedCount: number;
18
+ }
19
+
20
+ interface TrackedProcess {
21
+ pid: number;
22
+ sessionId: string;
23
+ snapshot: ProcessSnapshot;
24
+ }
25
+
26
+ export const resourceMonitor = new EventEmitter();
27
+
28
+ const tracked = new Map<number, TrackedProcess>();
29
+
30
+ let timer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ function startIfNeeded(): void {
33
+ if (timer) return;
34
+ timer = setInterval(checkAll, CHECK_INTERVAL_MS);
35
+ timer.unref?.();
36
+ }
37
+
38
+ function stopIfIdle(): void {
39
+ if (tracked.size > 0) return;
40
+ if (timer) { clearInterval(timer); timer = null; }
41
+ }
42
+
43
+ export function registerProcess(pid: number, sessionId: string): void {
44
+ tracked.set(pid, { pid, sessionId, snapshot: { cpu: -1, memory: -1, unchangedCount: 0 } });
45
+ startIfNeeded();
46
+ }
47
+
48
+ export function unregisterProcess(pid: number): void {
49
+ tracked.delete(pid);
50
+ stopIfIdle();
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // 批量查询进程指标(Windows PowerShell)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function execPowerShell(script: string, timeoutMs: number): Promise<string> {
58
+ return new Promise((resolve, reject) => {
59
+ exec(
60
+ `powershell -NoProfile -Command "${script}"`,
61
+ { timeout: timeoutMs, windowsHide: true },
62
+ (err, stdout) => {
63
+ if (err) reject(err);
64
+ else resolve(stdout);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ async function getProcessMetrics(pids: number[]): Promise<Map<number, { cpu: number; memory: number }>> {
71
+ const result = new Map<number, { cpu: number; memory: number }>();
72
+ if (pids.length === 0) return result;
73
+
74
+ const psScript = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)|$($_.CPU)|$($_.WorkingSet64)" }`;
75
+
76
+ try {
77
+ const stdout = await execPowerShell(psScript, 10_000);
78
+ for (const line of stdout.trim().split(/\r?\n/)) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed) continue;
81
+ const [idStr, cpuStr, memStr] = trimmed.split("|");
82
+ const id = parseInt(idStr, 10);
83
+ const cpu = parseFloat(cpuStr);
84
+ const memory = parseInt(memStr, 10);
85
+ if (!isNaN(id) && !isNaN(cpu) && !isNaN(memory)) {
86
+ result.set(id, { cpu, memory });
87
+ }
88
+ }
89
+ } catch {
90
+ // PowerShell 查询失败时跳过本轮,下次重试
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 定时检查
98
+ // ---------------------------------------------------------------------------
99
+
100
+ async function checkAll(): Promise<void> {
101
+ if (tracked.size === 0) return;
102
+
103
+ const pids = [...tracked.keys()];
104
+ const metrics = await getProcessMetrics(pids);
105
+
106
+ for (const [pid, tp] of tracked) {
107
+ const m = metrics.get(pid);
108
+ if (!m) {
109
+ // 进程已不存在,停止追踪
110
+ tracked.delete(pid);
111
+ continue;
112
+ }
113
+
114
+ const prev = tp.snapshot;
115
+ const cpuChanged = m.cpu !== prev.cpu;
116
+ // 内存允许 ±1% 波动,避免正常抖动触发误判
117
+ const memTolerance = prev.memory > 0 ? Math.max(prev.memory * 0.01, 1024 * 1024) : 1024 * 1024;
118
+ const memChanged = Math.abs(m.memory - prev.memory) > memTolerance;
119
+
120
+ if (!cpuChanged && !memChanged) {
121
+ tp.snapshot.unchangedCount++;
122
+ if (tp.snapshot.unchangedCount >= STUCK_THRESHOLD) {
123
+ const idleMinutes = Math.round(
124
+ (tp.snapshot.unchangedCount * CHECK_INTERVAL_MS) / 60_000,
125
+ );
126
+ resourceMonitor.emit("stuck", {
127
+ pid: tp.pid,
128
+ sessionId: tp.sessionId,
129
+ idleMinutes,
130
+ });
131
+ tracked.delete(pid);
132
+ }
133
+ } else {
134
+ tp.snapshot.unchangedCount = 0;
135
+ }
136
+ tp.snapshot.cpu = m.cpu;
137
+ tp.snapshot.memory = m.memory;
138
+ }
139
+
140
+ stopIfIdle();
141
+ }
@@ -71,6 +71,8 @@ export interface ActivePrompt {
71
71
  /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
72
  abnormalExit?: boolean;
73
73
  abnormalExitNotified?: boolean;
74
+ /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
+ resourceStuck?: boolean;
74
76
  }
75
77
 
76
78
  export const activePrompts = new Map<string, ActivePrompt>();
@@ -149,6 +151,8 @@ export interface DisplayCardState {
149
151
  lastSentFinalReply?: string;
150
152
  /** 点点点动画计数器(统一 display loop 每个卡片独立计数) */
151
153
  dotCount: number;
154
+ /** stream-state 连续读取失败次数,超过阈值才删除 display entry */
155
+ readFailCount?: number;
152
156
  }
153
157
 
154
158
  export const displayCards = new Map<string, DisplayCardState>();
package/src/session.ts CHANGED
@@ -29,6 +29,7 @@ import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
29
29
  import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
30
30
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
31
31
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
32
+ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
32
33
  import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
33
34
  import type { PlatformAdapter } from "./platform-adapter.ts";
34
35
 
@@ -181,7 +182,7 @@ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): vo
181
182
  clearPromptProcessMonitor(sessionId);
182
183
  return;
183
184
  }
184
- if (current.stopped || current.abnormalExit) return;
185
+ if (current.stopped || current.abnormalExit || current.resourceStuck) return;
185
186
  if (isProcessAliveImpl(info.pid)) return;
186
187
 
187
188
  current.abnormalExit = true;
@@ -877,6 +878,26 @@ export async function runAgentSession(
877
878
  startTime: now,
878
879
  });
879
880
 
881
+ // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
882
+ const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
883
+ if (data.sessionId !== sessionId) return;
884
+ const prompt = activePrompts.get(sessionId);
885
+ if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck) return;
886
+ prompt.resourceStuck = true;
887
+
888
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
889
+ const p = chatId ? platformForChat(chatId) : null;
890
+ if (chatId && p) {
891
+ p.sendText(
892
+ chatId,
893
+ `⚠️ 会话僵死:session ${sessionId.slice(0, 8)} 对应的 CLI 进程 PID ${data.pid} CPU 和内存连续 ${data.idleMinutes} 分钟无变化,已强制停止。若回复不完整,请重新发送上一条指令。`,
894
+ ).catch(() => {});
895
+ }
896
+
897
+ controller.abort();
898
+ };
899
+ resourceMonitor.on("stuck", onResourceStuck);
900
+
880
901
  // 异步准备工作(session info、IM skills prompt 等)
881
902
  let adapter: ToolAdapter;
882
903
  let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
@@ -1079,9 +1100,11 @@ export async function runAgentSession(
1079
1100
  for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1080
1101
  onProcessStart: (processInfo) => {
1081
1102
  startPromptProcessMonitor(sessionId, processInfo);
1103
+ if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
1082
1104
  },
1083
- onProcessExit: () => {
1105
+ onProcessExit: (exitInfo) => {
1084
1106
  clearPromptProcessMonitor(sessionId);
1107
+ if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1085
1108
  },
1086
1109
  })) {
1087
1110
  for (const block of unifiedMsg.blocks) {
@@ -1124,9 +1147,11 @@ export async function runAgentSession(
1124
1147
  console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1125
1148
  } finally {
1126
1149
  // 标记 prompt 结束
1150
+ resourceMonitor.off("stuck", onResourceStuck);
1127
1151
  const prompt = activePrompts.get(sessionId);
1128
1152
  const wasStopped = prompt?.stopped ?? false;
1129
1153
  const wasAbnormalExit = prompt?.abnormalExit ?? false;
1154
+ const wasResourceStuck = prompt?.resourceStuck ?? false;
1130
1155
  clearPromptProcessMonitor(sessionId);
1131
1156
  activePrompts.delete(sessionId);
1132
1157
 
@@ -1134,7 +1159,7 @@ export async function runAgentSession(
1134
1159
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1135
1160
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1136
1161
  // 运行中并更新旧卡片,而不是新建卡片。
1137
- const finalStatus = wasAbnormalExit ? "error" : wasStopped ? "stopped" : "done";
1162
+ const finalStatus = (wasAbnormalExit || wasResourceStuck) ? "error" : wasStopped ? "stopped" : "done";
1138
1163
  const finalReply = pickFinalReply(state).trim();
1139
1164
  await writeStreamState({
1140
1165
  sessionId,
@@ -1254,15 +1279,21 @@ export function startUnifiedDisplayLoop(): void {
1254
1279
  const sessionId = display.sessionId;
1255
1280
  const state = await readStreamState(sessionId);
1256
1281
  if (!state) {
1257
- displayCards.delete(chatId);
1282
+ display.readFailCount = (display.readFailCount ?? 0) + 1;
1283
+ if (display.readFailCount >= 3) {
1284
+ console.log(`[${ts()}] [DISPLAY] readStreamState ${display.readFailCount} consecutive failures for ${sessionId}, removing display entry for chat ${chatId}`);
1285
+ displayCards.delete(chatId);
1286
+ }
1258
1287
  continue;
1259
1288
  }
1289
+ display.readFailCount = 0;
1260
1290
 
1261
1291
  // 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
1262
1292
  // 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
1263
1293
  const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1264
1294
  if (currentSessionForChat && currentSessionForChat !== sessionId) {
1265
1295
  if (state.status !== "running") {
1296
+ console.log(`[${ts()}] [DISPLAY] chat ${chatId} now bound to ${currentSessionForChat}, removing stale display for ${sessionId} (terminal: ${state.status})`);
1266
1297
  displayCards.delete(chatId);
1267
1298
  }
1268
1299
  continue;
@@ -1272,6 +1303,7 @@ export function startUnifiedDisplayLoop(): void {
1272
1303
  const lastActive = getLastActiveChat(sessionId);
1273
1304
  if (lastActive !== chatId) {
1274
1305
  if (state.status !== "running") {
1306
+ console.log(`[${ts()}] [DISPLAY] lastActive mismatch for ${sessionId}: display chat=${chatId} lastActive=${lastActive ?? "undefined"}, removing display entry (terminal: ${state.status})`);
1275
1307
  displayCards.delete(chatId);
1276
1308
  }
1277
1309
  continue;