chatccc 0.2.81 → 0.2.82

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.81",
3
+ "version": "0.2.82",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,108 @@
1
+ // =============================================================================
2
+ // proc-tree-kill.test.ts — 进程树强杀工具单测护栏
3
+ // =============================================================================
4
+ // 目的:覆盖 codex/cursor adapter 之前 proc.kill() 在 Windows + shell:true 下
5
+ // 只杀第一层 cmd.exe 壳、留下 node + 真二进制成"幽灵"的 bug。
6
+ // 测试构造一棵三层进程树(祖父 cmd/sh → 父 node → 子 sleep 进程),
7
+ // 用 killProcessTree 杀掉祖父 PID,断言整棵树都不存在了。
8
+ // =============================================================================
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { spawn } from "node:child_process";
12
+ import { join } from "node:path";
13
+ import { writeFileSync, unlinkSync, existsSync, mkdtempSync, readFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { killProcessTree } from "../adapters/proc-tree-kill.ts";
16
+
17
+ function isAlive(pid: number): boolean {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ async function sleep(ms: number): Promise<void> {
27
+ await new Promise((r) => setTimeout(r, ms));
28
+ }
29
+
30
+ describe("killProcessTree", () => {
31
+ it("kills a 3-level process tree (shell wrapper + node child + node grandchild)", async () => {
32
+ // 用临时脚本模拟 codex CLI 的真实拓扑:
33
+ // layer-1: shell (cmd.exe / sh) — 因为 spawn 用了 shell:true
34
+ // layer-2: node 进程 — 模拟 `node codex.js` 入口
35
+ // layer-3: 又一个 node 进程 — 模拟真正干活的 codex.exe 子进程
36
+ const tmp = mkdtempSync(join(tmpdir(), "chatccc-proctree-"));
37
+ const childScript = join(tmp, "child.mjs");
38
+ const pidFile = join(tmp, "grand.pid");
39
+ const isWin = process.platform === "win32";
40
+
41
+ writeFileSync(
42
+ childScript,
43
+ [
44
+ `import { spawn } from "node:child_process";`,
45
+ `import { writeFileSync } from "node:fs";`,
46
+ // 启动孙进程:再开一个 node,跑一个 10 分钟的 setTimeout 占位
47
+ `const grand = spawn(process.execPath, ["-e", "setTimeout(()=>{},600000)"], { stdio: "ignore", detached: false });`,
48
+ `writeFileSync(${JSON.stringify(pidFile)}, String(grand.pid));`,
49
+ // 父进程自己也挂住别退出
50
+ `setTimeout(()=>{}, 600000);`,
51
+ ].join("\n"),
52
+ "utf8",
53
+ );
54
+
55
+ // 启动祖父:复现 codex-adapter 用 shell:true 的方式
56
+ // 路径必须加引号,否则 cmd.exe 把空格当分隔符
57
+ const proc = spawn(`"${process.execPath}" "${childScript}"`, {
58
+ stdio: "ignore",
59
+ shell: true,
60
+ windowsHide: true,
61
+ detached: !isWin,
62
+ });
63
+
64
+ expect(proc.pid).toBeGreaterThan(0);
65
+
66
+ // 等待孙进程 pid 被写出来(pidFile 出现表示中间 node 已成功 spawn 孙子)
67
+ let grandPid = 0;
68
+ for (let i = 0; i < 100; i++) {
69
+ if (existsSync(pidFile)) {
70
+ const s = readFileSync(pidFile, "utf8").trim();
71
+ if (s && /^\d+$/.test(s)) {
72
+ grandPid = parseInt(s, 10);
73
+ break;
74
+ }
75
+ }
76
+ await sleep(100);
77
+ }
78
+ expect(grandPid).toBeGreaterThan(0);
79
+ expect(isAlive(grandPid)).toBe(true);
80
+
81
+ // 杀祖父(在 shell:true 时 proc.pid 就是 cmd.exe / sh 那一层)。
82
+ // 关键断言:如果 killProcessTree 没有 /T 递归,grandPid 会留下来。
83
+ await killProcessTree(proc.pid!);
84
+
85
+ for (let i = 0; i < 50; i++) {
86
+ if (!isAlive(grandPid)) break;
87
+ await sleep(100);
88
+ }
89
+
90
+ expect(isAlive(grandPid)).toBe(false);
91
+ expect(isAlive(proc.pid!)).toBe(false);
92
+
93
+ try {
94
+ unlinkSync(childScript);
95
+ if (existsSync(pidFile)) unlinkSync(pidFile);
96
+ } catch {
97
+ // 忽略清理失败
98
+ }
99
+ }, 30000);
100
+
101
+ it("does not throw when pid does not exist", async () => {
102
+ await expect(killProcessTree(999999)).resolves.toBeUndefined();
103
+ });
104
+
105
+ it("does not throw when pid is undefined", async () => {
106
+ await expect(killProcessTree(undefined)).resolves.toBeUndefined();
107
+ });
108
+ });
@@ -0,0 +1,126 @@
1
+ // =============================================================================
2
+ // stop-session.test.ts — stopSession 单测护栏
3
+ // =============================================================================
4
+ // 覆盖修复"幽灵 codex 会话"的两条关键行为:
5
+ // 1) controller.abort() 必须被触发(让 adapter finally 走 killProcessTree)
6
+ // 2) 立刻把 stream-state.status 改成 stopped,不依赖 runAgentSession 的 finally
7
+ // (那个 finally 要等 generator 自然结束,子进程没死透就一直停在 running)
8
+ // =============================================================================
9
+
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+ import type { StreamState } from "../stream-state.ts";
12
+
13
+ // mock stream-state,使用模块内可观测的 Map 记录读写
14
+ const stateStore = new Map<string, StreamState>();
15
+ const writeCalls: StreamState[] = [];
16
+
17
+ vi.mock("../stream-state.ts", () => ({
18
+ readStreamState: async (sid: string): Promise<StreamState | null> => {
19
+ return stateStore.get(sid) ?? null;
20
+ },
21
+ writeStreamState: async (state: StreamState): Promise<void> => {
22
+ writeCalls.push(structuredClone(state));
23
+ stateStore.set(state.sessionId, state);
24
+ },
25
+ createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
26
+ sessionId: sid,
27
+ status: "running" as const,
28
+ accumulatedContent: "",
29
+ finalReply: "",
30
+ chunkCount: 0,
31
+ turnCount,
32
+ contextTokens: 0,
33
+ updatedAt: Date.now(),
34
+ cwd,
35
+ tool,
36
+ }),
37
+ fixStaleStreamStates: async () => {},
38
+ STREAMS_DIR: "/tmp/streams-mock",
39
+ }));
40
+
41
+ import { stopSession } from "../session.ts";
42
+ import { activePrompts } from "../session-chat-binding.ts";
43
+
44
+ function seedRunningSession(sid: string, accumulated = "partial output"): AbortController {
45
+ const controller = new AbortController();
46
+ activePrompts.set(sid, { controller, stopped: false, startTime: Date.now() });
47
+ stateStore.set(sid, {
48
+ sessionId: sid,
49
+ status: "running",
50
+ accumulatedContent: accumulated,
51
+ finalReply: "",
52
+ chunkCount: 1,
53
+ turnCount: 1,
54
+ contextTokens: 0,
55
+ updatedAt: Date.now(),
56
+ cwd: "F:/repo",
57
+ tool: "codex",
58
+ });
59
+ return controller;
60
+ }
61
+
62
+ async function flush(): Promise<void> {
63
+ // 让 stopSession 内 fire-and-forget 的 microtask 跑完
64
+ for (let i = 0; i < 5; i++) {
65
+ await Promise.resolve();
66
+ }
67
+ await new Promise((r) => setTimeout(r, 10));
68
+ }
69
+
70
+ beforeEach(() => {
71
+ activePrompts.clear();
72
+ stateStore.clear();
73
+ writeCalls.length = 0;
74
+ });
75
+
76
+ describe("stopSession 行为护栏", () => {
77
+ it("没有活跃 session 时返回 false,不做任何事", async () => {
78
+ const ok = stopSession("nonexistent");
79
+ expect(ok).toBe(false);
80
+ await flush();
81
+ expect(writeCalls).toHaveLength(0);
82
+ });
83
+
84
+ it("abort controller + 立刻把 stream-state.status 写成 stopped", async () => {
85
+ const controller = seedRunningSession("sid-A", "hello world");
86
+ let aborted = false;
87
+ controller.signal.addEventListener("abort", () => { aborted = true; });
88
+
89
+ const ok = stopSession("sid-A");
90
+ expect(ok).toBe(true);
91
+ expect(aborted).toBe(true);
92
+
93
+ await flush();
94
+
95
+ // 关键护栏:必须有一次 writeStreamState 把 status 改成 stopped
96
+ expect(writeCalls.length).toBeGreaterThanOrEqual(1);
97
+ const lastWrite = writeCalls[writeCalls.length - 1];
98
+ expect(lastWrite.sessionId).toBe("sid-A");
99
+ expect(lastWrite.status).toBe("stopped");
100
+ // 累积内容不丢
101
+ expect(lastWrite.accumulatedContent).toBe("hello world");
102
+ });
103
+
104
+ it("已经是终态(done/stopped/error)的 stream-state 不会被覆盖", async () => {
105
+ seedRunningSession("sid-B");
106
+ // 模拟 generator finally 已经先写了 done
107
+ stateStore.set("sid-B", {
108
+ ...stateStore.get("sid-B")!,
109
+ status: "done",
110
+ });
111
+
112
+ const ok = stopSession("sid-B");
113
+ expect(ok).toBe(true);
114
+ await flush();
115
+
116
+ // 不应再有 stopped 覆盖 done 的写入
117
+ const stoppedWrites = writeCalls.filter((w) => w.status === "stopped");
118
+ expect(stoppedWrites).toHaveLength(0);
119
+ });
120
+
121
+ it("activePrompts 标记 stopped=true", async () => {
122
+ seedRunningSession("sid-C");
123
+ stopSession("sid-C");
124
+ expect(activePrompts.get("sid-C")?.stopped).toBe(true);
125
+ });
126
+ });
@@ -22,6 +22,7 @@ import {
22
22
  defaultCodexSessionMetaStore,
23
23
  type CodexSessionMetaStore,
24
24
  } from "./codex-session-meta-store.ts";
25
+ import { killProcessTree } from "./proc-tree-kill.ts";
25
26
  import { config } from "../config.ts";
26
27
 
27
28
  // ---------------------------------------------------------------------------
@@ -242,14 +243,17 @@ class CodexAdapter implements ToolAdapter {
242
243
 
243
244
  const proc = spawnCodex(args, cwd, userText);
244
245
 
245
- const onAbort = () => { proc.kill(); };
246
+ // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
247
+ // 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
248
+ // 会留下幽灵 node + codex.exe 继续烧 token、stream-state 永远停在 running。
249
+ // 因此 abort 与 finally 都必须用 killProcessTree 整棵进程树一起收尸。
250
+ const onAbort = () => { void killProcessTree(proc.pid); };
246
251
  signal?.addEventListener("abort", onAbort, { once: true });
247
252
 
248
253
  try {
249
254
  for await (const raw of readJsonLines(proc, signal)) {
250
255
  if (signal?.aborted) break;
251
256
 
252
- // 首次 prompt 时从 thread.started 事件学习 threadId
253
257
  if (
254
258
  isFirstPrompt &&
255
259
  raw.type === "thread.started" &&
@@ -265,7 +269,7 @@ class CodexAdapter implements ToolAdapter {
265
269
  }
266
270
  } finally {
267
271
  signal?.removeEventListener("abort", onAbort);
268
- proc.kill();
272
+ await killProcessTree(proc.pid);
269
273
  }
270
274
  }
271
275
 
@@ -20,6 +20,7 @@ import {
20
20
  defaultCursorSessionMetaStore,
21
21
  type CursorSessionMetaStore,
22
22
  } from "./cursor-session-meta-store.ts";
23
+ import { killProcessTree } from "./proc-tree-kill.ts";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // 类型:Cursor JSONL 消息行
@@ -319,20 +320,16 @@ class CursorAdapter implements ToolAdapter {
319
320
  for await (const msg of readJsonLines(proc)) {
320
321
  if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
321
322
  const sessionId = msg.session_id;
322
- // 持久化 sessionId → { cwd, model }:getSessionInfo 后续依赖此映射,
323
- // 否则 Cursor 会话上的 /git、/status、/sessions 等命令将无法显示
324
- // 真实工作目录与真实模型。优先用 init 事件自报字段(cursor-agent
325
- // 内部权威),cwd 没有时退回调用方传入的 cwd 兜底。
326
323
  await this.metaStore
327
324
  .set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
328
325
  .catch(() => {});
329
326
  this.activeProcs.delete(proc);
330
- proc.kill();
327
+ await killProcessTree(proc.pid);
331
328
  return { sessionId };
332
329
  }
333
330
  }
334
331
 
335
- proc.kill();
332
+ await killProcessTree(proc.pid);
336
333
  this.activeProcs.delete(proc);
337
334
  throw new Error("No session ID in Cursor init event");
338
335
  }
@@ -346,16 +343,14 @@ class CursorAdapter implements ToolAdapter {
346
343
  const proc = spawnAgent(["--resume", sessionId], cwd, userText);
347
344
  this.activeProcs.add(proc);
348
345
 
349
- const onAbort = () => { proc.kill(); };
346
+ // codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
347
+ // 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
348
+ const onAbort = () => { void killProcessTree(proc.pid); };
350
349
  signal?.addEventListener("abort", onAbort, { once: true });
351
350
 
352
351
  try {
353
352
  for await (const raw of readJsonLines(proc, signal)) {
354
353
  if (signal?.aborted) break;
355
- // 自动学习:resume 流首条 init 事件若带 cwd / model,更新映射。
356
- // 这是为升级前创建的旧会话或映射文件意外丢失的情况兜底——
357
- // 用户向旧会话发一次消息后,/git、/status 等即可正常显示。
358
- // fire-and-forget:写失败不影响流式输出。
359
354
  if (
360
355
  raw.type === "system" &&
361
356
  raw.subtype === "init" &&
@@ -371,7 +366,7 @@ class CursorAdapter implements ToolAdapter {
371
366
  }
372
367
  } finally {
373
368
  signal?.removeEventListener("abort", onAbort);
374
- proc.kill();
369
+ await killProcessTree(proc.pid);
375
370
  this.activeProcs.delete(proc);
376
371
  }
377
372
  }
@@ -0,0 +1,97 @@
1
+ // =============================================================================
2
+ // proc-tree-kill.ts — 跨平台进程树强杀工具
3
+ // =============================================================================
4
+ // 背景:codex / cursor adapter 通过 `spawn(cmd, args, { shell: true })` 启动 CLI
5
+ // 时,Node 拿到的 proc.pid 是最外层 cmd.exe(Windows)或 /bin/sh(其它)的
6
+ // PID。真正干活的是它再 spawn 出来的:
7
+ //
8
+ // cmd.exe ← proc.kill() 只能杀到这一层
9
+ // └─ node codex.js ← Codex CLI 入口
10
+ // └─ codex.exe ← 实际 Rust 二进制(继续烧 token)
11
+ //
12
+ // 单纯 proc.kill() 在 Windows 上等价于 TerminateProcess 顶层壳,孙子进程不会
13
+ // 收到任何信号、继续运行,导致用户 /stop 看似生效(adapter 标记 stopped)但
14
+ // 实际 codex 仍在后台跑、stream-state 一直停在 "running"。
15
+ //
16
+ // 解决方案:abort 时不要走 proc.kill(),而是用本工具按 pid 杀掉整棵进程树。
17
+ // - Windows: `taskkill /pid <pid> /T /F`(/T = 递归子进程, /F = 强制)
18
+ // - 其它:`process.kill(-pgid, "SIGTERM")` + 兜底 SIGKILL(adapter spawn 时
19
+ // 需配合 detached:true 让子进程拥有独立 process group)
20
+ // =============================================================================
21
+
22
+ import { spawn } from "node:child_process";
23
+
24
+ /** 异步杀掉以 pid 为根的整棵进程树。
25
+ *
26
+ * 设计目标:永不抛错、永不阻塞调用者。
27
+ * - pid 不存在、参数缺失 → 静默返回
28
+ * - 子进程 spawn 失败 → console.warn 但不 reject
29
+ * - Windows 上 taskkill 异步执行,不阻塞 event loop
30
+ *
31
+ * 调用方约定:返回的 Promise 在 kill 命令发出后立即 resolve。
32
+ * 真正的进程退出由 OS 异步完成,调用方如果需要确认"已死透",应自己再轮询
33
+ * `process.kill(pid, 0)`。
34
+ */
35
+ export async function killProcessTree(pid: number | undefined): Promise<void> {
36
+ if (pid == null || !Number.isFinite(pid) || pid <= 0) return;
37
+ if (process.platform === "win32") {
38
+ await killWindowsTree(pid);
39
+ return;
40
+ }
41
+ await killPosixTree(pid);
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Windows: taskkill /T /F
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function killWindowsTree(pid: number): Promise<void> {
49
+ return new Promise<void>((resolve) => {
50
+ let resolved = false;
51
+ const done = () => {
52
+ if (resolved) return;
53
+ resolved = true;
54
+ resolve();
55
+ };
56
+ try {
57
+ const proc = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
58
+ stdio: "ignore",
59
+ windowsHide: true,
60
+ // taskkill 本身很快(<200ms),不需要 detached
61
+ });
62
+ proc.once("error", (err) => {
63
+ console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${(err as Error).message}`);
64
+ done();
65
+ });
66
+ proc.once("close", () => { done(); });
67
+ // 兜底超时:3 秒后强制 resolve,避免极端情况下 hang 住调用方
68
+ setTimeout(done, 3000).unref();
69
+ } catch (err) {
70
+ console.warn(`[killProcessTree] taskkill failed for pid=${pid}: ${(err as Error).message}`);
71
+ done();
72
+ }
73
+ });
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // POSIX: 优先按 process group 杀,回退到按 pid 杀
78
+ // ---------------------------------------------------------------------------
79
+
80
+ async function killPosixTree(pid: number): Promise<void> {
81
+ // 第一次尝试:按 process group 发 SIGTERM。要求 spawn 时 detached:true。
82
+ trySignal(-pid, "SIGTERM");
83
+ trySignal(pid, "SIGTERM");
84
+ // 给进程 1 秒优雅退出机会
85
+ await new Promise((r) => setTimeout(r, 1000));
86
+ // 兜底:SIGKILL
87
+ trySignal(-pid, "SIGKILL");
88
+ trySignal(pid, "SIGKILL");
89
+ }
90
+
91
+ function trySignal(target: number, signal: NodeJS.Signals): void {
92
+ try {
93
+ process.kill(target, signal);
94
+ } catch {
95
+ // 进程已不存在或权限不足,忽略
96
+ }
97
+ }
package/src/session.ts CHANGED
@@ -1250,7 +1250,18 @@ export function ensureDisplayLoop(sessionId: string): void {
1250
1250
  // ---------------------------------------------------------------------------
1251
1251
  // stopSession — 停止指定 session 的活跃 prompt
1252
1252
  // ---------------------------------------------------------------------------
1253
-
1253
+ //
1254
+ // 设计要点:
1255
+ // 1) controller.abort() 触发 adapter finally 里的 killProcessTree(proc.pid),
1256
+ // 后者负责把整棵 CLI 进程树(cmd.exe 壳 + node CLI 入口 + 真二进制)一起
1257
+ // 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
1258
+ // 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
1259
+ //
1260
+ // 2) 立刻 fire-and-forget 把 stream-state 标 stopped,不依赖 runAgentSession
1261
+ // 的 finally。原因:generator 自然结束依赖子进程 stdout 关闭,killProcessTree
1262
+ // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
1263
+ // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
1264
+ // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
1254
1265
  export function stopSession(sessionId: string): boolean {
1255
1266
  const prompt = activePrompts.get(sessionId);
1256
1267
  if (!prompt) return false;
@@ -1258,6 +1269,27 @@ export function stopSession(sessionId: string): boolean {
1258
1269
  cancelQueuedMessage(sessionId);
1259
1270
  prompt.controller.abort();
1260
1271
  console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
1272
+
1273
+ // fire-and-forget:立刻把 stream-state.status 改成 stopped,
1274
+ // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
1275
+ void (async () => {
1276
+ try {
1277
+ const current = await readStreamState(sessionId);
1278
+ if (!current) return;
1279
+ // 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
1280
+ if (current.status !== "running") return;
1281
+ await writeStreamState({
1282
+ ...current,
1283
+ status: "stopped",
1284
+ updatedAt: Date.now(),
1285
+ });
1286
+ } catch (err) {
1287
+ console.warn(
1288
+ `[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${(err as Error).message}`,
1289
+ );
1290
+ }
1291
+ })();
1292
+
1261
1293
  return true;
1262
1294
  }
1263
1295