chatccc 0.2.94 → 0.2.96

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.
@@ -1,59 +1,59 @@
1
- // postinstall 检测 sharp 原生模块是否可用(常见于 Linux 缺少 libvips)
2
- import { createRequire } from "node:module";
3
-
4
- const require = createRequire(import.meta.url);
5
- let ok = false;
6
-
7
- try {
8
- require("sharp");
9
- ok = true;
10
- } catch (err) {
11
- const msg = err.message;
12
- const platform = process.platform;
13
-
14
- console.warn("");
15
- console.warn("============================================================");
16
- console.warn(" ChatCCC: sharp 图像处理模块检测失败");
17
- console.warn("============================================================");
18
-
19
- if (platform === "linux") {
20
- if (msg.includes("libvips") || msg.includes("vips") || msg.includes("libglib")) {
21
- console.warn(" 原因:系统缺少 libvips 图像处理库。");
22
- console.warn("");
23
- console.warn(" 请先安装 libvips 然后重建 sharp:");
24
- console.warn("");
25
- console.warn(" Debian / Ubuntu:");
26
- console.warn(" sudo apt install libvips-dev");
27
- console.warn("");
28
- console.warn(" CentOS / RHEL / Fedora:");
29
- console.warn(" sudo yum install vips-devel");
30
- console.warn("");
31
- console.warn(" Alpine:");
32
- console.warn(" sudo apk add vips-dev");
33
- console.warn("");
34
- console.warn(" 安装 libvips 后重建 sharp:");
35
- console.warn(" npm install -g chatccc --force");
36
- } else if (msg.includes("Cannot find module")) {
37
- console.warn(" 原因:sharp 模块未成功安装。");
38
- console.warn("");
39
- console.warn(" 请尝试重建(可能需要先安装 libvips):");
40
- console.warn(" npm install -g chatccc --force");
41
- } else {
42
- console.warn(" 错误详情:", msg);
43
- }
44
- } else {
45
- console.warn(" 错误详情:", msg);
46
- console.warn(" 平台:", platform);
47
- console.warn(" 请尝试:npm install -g chatccc --force");
48
- }
49
-
50
- console.warn("");
51
- console.warn(" 缺少 sharp 不影响消息收发,但群聊头像状态指示");
52
- console.warn(" (运行中/空闲/新建)将不会更新。");
53
- console.warn("============================================================");
54
- console.warn("");
55
- }
56
-
57
- if (!ok && process.env.npm_config_verbose) {
58
- process.exitCode = 0; // 永远不阻塞安装
1
+ // postinstall 检测 sharp 原生模块是否可用(常见于 Linux 缺少 libvips)
2
+ import { createRequire } from "node:module";
3
+
4
+ const require = createRequire(import.meta.url);
5
+ let ok = false;
6
+
7
+ try {
8
+ require("sharp");
9
+ ok = true;
10
+ } catch (err) {
11
+ const msg = err.message;
12
+ const platform = process.platform;
13
+
14
+ console.warn("");
15
+ console.warn("============================================================");
16
+ console.warn(" ChatCCC: sharp 图像处理模块检测失败");
17
+ console.warn("============================================================");
18
+
19
+ if (platform === "linux") {
20
+ if (msg.includes("libvips") || msg.includes("vips") || msg.includes("libglib")) {
21
+ console.warn(" 原因:系统缺少 libvips 图像处理库。");
22
+ console.warn("");
23
+ console.warn(" 请先安装 libvips 然后重建 sharp:");
24
+ console.warn("");
25
+ console.warn(" Debian / Ubuntu:");
26
+ console.warn(" sudo apt install libvips-dev");
27
+ console.warn("");
28
+ console.warn(" CentOS / RHEL / Fedora:");
29
+ console.warn(" sudo yum install vips-devel");
30
+ console.warn("");
31
+ console.warn(" Alpine:");
32
+ console.warn(" sudo apk add vips-dev");
33
+ console.warn("");
34
+ console.warn(" 安装 libvips 后重建 sharp:");
35
+ console.warn(" npm install -g chatccc --force");
36
+ } else if (msg.includes("Cannot find module")) {
37
+ console.warn(" 原因:sharp 模块未成功安装。");
38
+ console.warn("");
39
+ console.warn(" 请尝试重建(可能需要先安装 libvips):");
40
+ console.warn(" npm install -g chatccc --force");
41
+ } else {
42
+ console.warn(" 错误详情:", msg);
43
+ }
44
+ } else {
45
+ console.warn(" 错误详情:", msg);
46
+ console.warn(" 平台:", platform);
47
+ console.warn(" 请尝试:npm install -g chatccc --force");
48
+ }
49
+
50
+ console.warn("");
51
+ console.warn(" 缺少 sharp 不影响消息收发,但群聊头像状态指示");
52
+ console.warn(" (运行中/空闲/新建)将不会更新。");
53
+ console.warn("============================================================");
54
+ console.warn("");
55
+ }
56
+
57
+ if (!ok && process.env.npm_config_verbose) {
58
+ process.exitCode = 0; // 永远不阻塞安装
59
59
  }
@@ -90,6 +90,10 @@ import {
90
90
  runAgentSession,
91
91
  startUnifiedDisplayLoop,
92
92
  stopUnifiedDisplayLoop,
93
+ _setProcessAliveForTest,
94
+ _resetProcessAliveForTest,
95
+ _setProcessMonitorIntervalForTest,
96
+ _resetProcessMonitorIntervalForTest,
93
97
  } from "../session.ts";
94
98
  import {
95
99
  activePrompts,
@@ -103,7 +107,7 @@ import {
103
107
  displayCards,
104
108
  } from "../session-chat-binding.ts";
105
109
  import type { AccumulatorState } from "../session.ts";
106
- import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
110
+ import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
107
111
  import type { PlatformAdapter } from "../platform-adapter.ts";
108
112
 
109
113
  // Helper to create a mock active session entry
@@ -308,6 +312,92 @@ describe("runAgentSession previous final delivery guard", () => {
308
312
  });
309
313
  });
310
314
 
315
+ describe("runAgentSession process monitor", () => {
316
+ let registryFile = "";
317
+ let toolsFile = "";
318
+
319
+ beforeEach(async () => {
320
+ vi.useFakeTimers();
321
+ resetState();
322
+ resetBindingState();
323
+ mockStreamStates.clear();
324
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-process-monitor-"));
325
+ registryFile = join(dir, "session-registry.json");
326
+ toolsFile = join(dir, "session-tools.json");
327
+ _setSessionRegistryFileForTest(registryFile);
328
+ _setSessionToolsFileForTest(toolsFile);
329
+ _setProcessMonitorIntervalForTest(50);
330
+ });
331
+
332
+ afterEach(() => {
333
+ _resetSessionRegistryFileForTest();
334
+ _resetSessionToolsFileForTest();
335
+ _clearAdapterCacheForTest();
336
+ _resetProcessAliveForTest();
337
+ _resetProcessMonitorIntervalForTest();
338
+ resetBindingState();
339
+ vi.useRealTimers();
340
+ });
341
+
342
+ it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
343
+ const platform = mockPlatform("feishu");
344
+ setSessionPlatform(platform);
345
+ bindChatToSession("sid-process", "chat-process");
346
+ recordLastActiveChat("sid-process", "chat-process");
347
+ _setProcessAliveForTest(() => false);
348
+
349
+ const adapter: ToolAdapter = {
350
+ displayName: "Cursor",
351
+ sessionDescPrefix: "Cursor Session:",
352
+ createSession: async () => ({ sessionId: "sid-process" }),
353
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
354
+ closeSession: async () => {},
355
+ prompt: async function* (
356
+ _sid: string,
357
+ _text: string,
358
+ _cwd: string,
359
+ signal?: AbortSignal,
360
+ options?: ToolPromptOptions,
361
+ ) {
362
+ options?.onProcessStart?.({ pid: 12345 });
363
+ yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
364
+ await new Promise<void>((resolve) => {
365
+ if (signal?.aborted) {
366
+ resolve();
367
+ return;
368
+ }
369
+ signal?.addEventListener("abort", () => resolve(), { once: true });
370
+ });
371
+ },
372
+ };
373
+ _setAdapterForToolForTest("cursor", adapter);
374
+
375
+ const runPromise = runAgentSession(
376
+ "sid-process",
377
+ "prompt",
378
+ platform,
379
+ "chat-process",
380
+ Date.now(),
381
+ "cursor",
382
+ );
383
+
384
+ await vi.waitFor(() => {
385
+ expect(activePrompts.get("sid-process")?.processPid).toBe(12345);
386
+ });
387
+ await vi.advanceTimersByTimeAsync(60);
388
+ await runPromise;
389
+
390
+ const state = mockStreamStates.get("sid-process");
391
+ expect(state?.status).toBe("error");
392
+ expect(state?.finalReply).toContain("partial answer");
393
+ expect(activePrompts.has("sid-process")).toBe(false);
394
+ expect(platform.sendText).toHaveBeenCalledWith(
395
+ "chat-process",
396
+ expect.stringContaining("进程异常结束"),
397
+ );
398
+ });
399
+ });
400
+
311
401
  describe("unified display loop WeChat delta", () => {
312
402
  beforeEach(() => {
313
403
  vi.useFakeTimers();
@@ -15,12 +15,12 @@ vi.mock("../config.ts", async () => {
15
15
  });
16
16
 
17
17
  const {
18
- writeStreamState,
19
- readStreamState,
20
- createEmptyStreamState,
21
- isFinalReplySentForTurn,
22
- markFinalReplySent,
23
- STREAMS_DIR,
18
+ writeStreamState,
19
+ readStreamState,
20
+ createEmptyStreamState,
21
+ isFinalReplySentForTurn,
22
+ markFinalReplySent,
23
+ STREAMS_DIR,
24
24
  _setRenameForTest,
25
25
  _resetRenameForTest,
26
26
  } = await import("../stream-state.ts");
@@ -119,36 +119,36 @@ describe("writeStreamState — atomic rename", () => {
119
119
  const got = await readStreamState("never-existed");
120
120
  expect(got).toBeNull();
121
121
  });
122
- it("marks final reply delivery for the matching terminal turn only", async () => {
123
- const state = createEmptyStreamState("sid-delivered", "/tmp", "claude", 2);
124
- state.status = "done";
125
- state.finalReply = "done";
126
- await writeStreamState(state);
127
-
128
- await markFinalReplySent("sid-delivered", 2, 12345);
129
-
130
- const got = await readStreamState("sid-delivered");
131
- expect(got).not.toBeNull();
132
- expect(got!.finalReplySentTurn).toBe(2);
133
- expect(got!.finalReplySentAt).toBe(12345);
134
- expect(isFinalReplySentForTurn(got!)).toBe(true);
135
- });
136
-
137
- it("does not mark a running state or a different turn as delivered", async () => {
138
- const state = createEmptyStreamState("sid-running", "/tmp", "claude", 3);
139
- await writeStreamState(state);
140
-
141
- await markFinalReplySent("sid-running", 2, 12345);
142
- await markFinalReplySent("sid-running", 3, 12345);
143
-
144
- const got = await readStreamState("sid-running");
145
- expect(got).not.toBeNull();
146
- expect(got!.finalReplySentTurn).toBeUndefined();
147
- expect(isFinalReplySentForTurn(got!)).toBe(false);
148
- });
149
- });
150
-
151
- describe("createEmptyStreamState", () => {
122
+ it("marks final reply delivery for the matching terminal turn only", async () => {
123
+ const state = createEmptyStreamState("sid-delivered", "/tmp", "claude", 2);
124
+ state.status = "done";
125
+ state.finalReply = "done";
126
+ await writeStreamState(state);
127
+
128
+ await markFinalReplySent("sid-delivered", 2, 12345);
129
+
130
+ const got = await readStreamState("sid-delivered");
131
+ expect(got).not.toBeNull();
132
+ expect(got!.finalReplySentTurn).toBe(2);
133
+ expect(got!.finalReplySentAt).toBe(12345);
134
+ expect(isFinalReplySentForTurn(got!)).toBe(true);
135
+ });
136
+
137
+ it("does not mark a running state or a different turn as delivered", async () => {
138
+ const state = createEmptyStreamState("sid-running", "/tmp", "claude", 3);
139
+ await writeStreamState(state);
140
+
141
+ await markFinalReplySent("sid-running", 2, 12345);
142
+ await markFinalReplySent("sid-running", 3, 12345);
143
+
144
+ const got = await readStreamState("sid-running");
145
+ expect(got).not.toBeNull();
146
+ expect(got!.finalReplySentTurn).toBeUndefined();
147
+ expect(isFinalReplySentForTurn(got!)).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("createEmptyStreamState", () => {
152
152
  it("生成的 state 字段正确且 status=running", () => {
153
153
  const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
154
154
  expect(s.sessionId).toBe("sid-X");
@@ -156,9 +156,9 @@ describe("createEmptyStreamState", () => {
156
156
  expect(s.tool).toBe("cursor");
157
157
  expect(s.turnCount).toBe(5);
158
158
  expect(s.status).toBe("running");
159
- expect(s.accumulatedContent).toBe("");
160
- expect(s.finalReply).toBe("");
161
- expect(s.finalReplySentTurn).toBeUndefined();
162
- expect(s.chunkCount).toBe(0);
163
- });
164
- });
159
+ expect(s.accumulatedContent).toBe("");
160
+ expect(s.finalReply).toBe("");
161
+ expect(s.finalReplySentTurn).toBeUndefined();
162
+ expect(s.chunkCount).toBe(0);
163
+ });
164
+ });
@@ -113,6 +113,18 @@ export interface SessionInfo {
113
113
  model?: string;
114
114
  }
115
115
 
116
+ export interface ToolProcessInfo {
117
+ /** Root PID returned by child_process.spawn. With shell:true this is the shell wrapper PID. */
118
+ pid: number;
119
+ }
120
+
121
+ export interface ToolPromptOptions {
122
+ /** Called once a CLI-backed prompt process has been spawned. */
123
+ onProcessStart?: (info: ToolProcessInfo) => void;
124
+ /** Called when the adapter leaves the prompt process scope normally or by abort. */
125
+ onProcessExit?: (info: ToolProcessInfo) => void;
126
+ }
127
+
116
128
  // ---------------------------------------------------------------------------
117
129
  // ToolAdapter — 统一的 AI 工具适配器接口
118
130
  // ---------------------------------------------------------------------------
@@ -139,6 +151,7 @@ export interface ToolAdapter {
139
151
  userText: string,
140
152
  cwd: string,
141
153
  signal?: AbortSignal,
154
+ options?: ToolPromptOptions,
142
155
  ): AsyncIterable<UnifiedStreamMessage>;
143
156
 
144
157
  /**
@@ -13,6 +13,7 @@ import { randomUUID } from "node:crypto";
13
13
 
14
14
  import type {
15
15
  ToolAdapter,
16
+ ToolPromptOptions,
16
17
  UnifiedBlock,
17
18
  UnifiedStreamMessage,
18
19
  CreateSessionResult,
@@ -238,6 +239,7 @@ class CodexAdapter implements ToolAdapter {
238
239
  userText: string,
239
240
  cwd: string,
240
241
  signal?: AbortSignal,
242
+ options?: ToolPromptOptions,
241
243
  ): AsyncIterable<UnifiedStreamMessage> {
242
244
  let meta = await this.metaStore.get(sessionId);
243
245
  const threadId = meta?.threadId;
@@ -250,6 +252,7 @@ class CodexAdapter implements ToolAdapter {
250
252
  : [...CODEX_BASE_ARGS, "resume", threadId, "-"];
251
253
 
252
254
  const proc = spawnCodex(args, cwd, userText);
255
+ if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
253
256
 
254
257
  // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
255
258
  // 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
@@ -278,6 +281,7 @@ class CodexAdapter implements ToolAdapter {
278
281
  } finally {
279
282
  signal?.removeEventListener("abort", onAbort);
280
283
  await killProcessTree(proc.pid);
284
+ if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
281
285
  }
282
286
  }
283
287
 
@@ -10,6 +10,7 @@ import { createInterface } from "node:readline";
10
10
 
11
11
  import type {
12
12
  ToolAdapter,
13
+ ToolPromptOptions,
13
14
  UnifiedBlock,
14
15
  UnifiedStreamMessage,
15
16
  CreateSessionResult,
@@ -354,10 +355,12 @@ class CursorAdapter implements ToolAdapter {
354
355
  userText: string,
355
356
  cwd: string,
356
357
  signal?: AbortSignal,
358
+ options?: ToolPromptOptions,
357
359
  ): AsyncIterable<UnifiedStreamMessage> {
358
360
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
359
361
  const proc = spawnAgent(["--resume", sessionId], cwd, userText);
360
362
  this.activeProcs.add(proc);
363
+ if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
361
364
 
362
365
  // 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
363
366
  // 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
@@ -384,6 +387,7 @@ class CursorAdapter implements ToolAdapter {
384
387
  signal?.removeEventListener("abort", onAbort);
385
388
  await killProcessTree(proc.pid);
386
389
  this.activeProcs.delete(proc);
390
+ if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
387
391
  console.log(`[Cursor debug] prompt end: sessionId=${sessionId}, signalAborted=${signal?.aborted ?? false}`);
388
392
  }
389
393
  }