chatccc 0.2.115 → 0.2.116

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.116",
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>();
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,