chatccc 0.2.81 → 0.2.83

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,63 +1,63 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.81",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "main": "./src/index.ts",
8
- "bin": {
9
- "chatccc": "bin/chatccc.mjs"
10
- },
11
- "files": [
12
- "src/",
13
- "bin/",
14
- "scripts/postinstall-sharp-check.mjs",
15
- "demo/ilink_echo_probe.ts",
16
- "im-skills/",
17
- "images/img_readme_*.jpg",
18
- "images/img_readme_*.png",
19
- "images/avatars/status_*.png",
20
- "images/avatars/badges/",
21
- "package.json",
22
- "README.md",
23
- "config.sample.json"
24
- ],
25
- "scripts": {
26
- "dev": "tsx src/index.ts",
27
- "chatccc": "tsx src/index.ts",
28
- "start": "tsx src/index.ts",
29
- "demo:bot-test": "tsx demo/bot_test.ts",
30
- "demo:bot-test:local": "tsx demo/bot_test.ts --local",
31
- "demo:create-group": "tsx src/index.ts",
32
- "demo:create-group:local": "tsx src/index.ts --local",
33
- "demo:permission-check": "tsx demo/permission_check.ts",
34
- "demo:claude-hi": "tsx demo/claude_say_hi.ts",
35
- "demo:codex-hi": "tsx demo/codex_say_hi.ts",
36
- "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
37
- "test": "vitest run",
38
- "test:watch": "vitest",
39
- "postinstall": "node scripts/postinstall-sharp-check.mjs"
40
- },
41
- "dependencies": {
42
- "@ai-sdk/openai-compatible": "^2.0.47",
43
- "@anthropic-ai/claude-agent-sdk": "0.2.133",
44
- "@larksuiteoapi/node-sdk": "^1.59.0",
45
- "@openilink/openilink-sdk-node": "^0.6.0",
46
- "ai": "^6.0.184",
47
- "nodemailer": "^8.0.7",
48
- "qrcode-terminal": "^0.12.0",
49
- "sharp": "^0.34.5",
50
- "tsx": "^4.0.0",
51
- "ws": "^8.18.0"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^20.0.0",
55
- "@types/qrcode-terminal": "^0.12.2",
56
- "@types/ws": "^8.18.1",
57
- "typescript": "^5.0.0",
58
- "vitest": "^3.2.4"
59
- },
60
- "engines": {
61
- "node": ">=20"
62
- }
63
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.83",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "bin": {
9
+ "chatccc": "bin/chatccc.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "scripts/postinstall-sharp-check.mjs",
15
+ "demo/ilink_echo_probe.ts",
16
+ "im-skills/",
17
+ "images/img_readme_*.jpg",
18
+ "images/img_readme_*.png",
19
+ "images/avatars/status_*.png",
20
+ "images/avatars/badges/",
21
+ "package.json",
22
+ "README.md",
23
+ "config.sample.json"
24
+ ],
25
+ "scripts": {
26
+ "dev": "tsx src/index.ts",
27
+ "chatccc": "tsx src/index.ts",
28
+ "start": "tsx src/index.ts",
29
+ "demo:bot-test": "tsx demo/bot_test.ts",
30
+ "demo:bot-test:local": "tsx demo/bot_test.ts --local",
31
+ "demo:create-group": "tsx src/index.ts",
32
+ "demo:create-group:local": "tsx src/index.ts --local",
33
+ "demo:permission-check": "tsx demo/permission_check.ts",
34
+ "demo:claude-hi": "tsx demo/claude_say_hi.ts",
35
+ "demo:codex-hi": "tsx demo/codex_say_hi.ts",
36
+ "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "postinstall": "node scripts/postinstall-sharp-check.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@ai-sdk/openai-compatible": "^2.0.47",
43
+ "@anthropic-ai/claude-agent-sdk": "0.2.133",
44
+ "@larksuiteoapi/node-sdk": "^1.59.0",
45
+ "@openilink/openilink-sdk-node": "^0.6.0",
46
+ "ai": "^6.0.184",
47
+ "nodemailer": "^8.0.7",
48
+ "qrcode-terminal": "^0.12.0",
49
+ "sharp": "^0.34.5",
50
+ "tsx": "^4.0.0",
51
+ "ws": "^8.18.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "@types/qrcode-terminal": "^0.12.2",
56
+ "@types/ws": "^8.18.1",
57
+ "typescript": "^5.0.0",
58
+ "vitest": "^3.2.4"
59
+ },
60
+ "engines": {
61
+ "node": ">=20"
62
+ }
63
+ }
@@ -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
  // ---------------------------------------------------------------------------
@@ -192,15 +193,23 @@ async function* readJsonLines(
192
193
  signal?: AbortSignal,
193
194
  ): AsyncGenerator<CodexEvent> {
194
195
  const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
195
- for await (const line of rl) {
196
- if (signal?.aborted) break;
197
- const trimmed = line.trim();
198
- if (!trimmed) continue;
199
- try {
200
- yield JSON.parse(trimmed) as CodexEvent;
201
- } catch {
202
- // JSON 行静默跳过(如 "Reading prompt from stdin...")
196
+ // abort 时主动 close readline,避免等待 Windows 管道自然关闭(可能延迟数分钟)
197
+ const onAbort = () => { rl.close(); };
198
+ signal?.addEventListener("abort", onAbort, { once: true });
199
+ try {
200
+ for await (const line of rl) {
201
+ if (signal?.aborted) break;
202
+ const trimmed = line.trim();
203
+ if (!trimmed) continue;
204
+ try {
205
+ yield JSON.parse(trimmed) as CodexEvent;
206
+ } catch {
207
+ // 非 JSON 行静默跳过(如 "Reading prompt from stdin...")
208
+ }
203
209
  }
210
+ } finally {
211
+ signal?.removeEventListener("abort", onAbort);
212
+ rl.close();
204
213
  }
205
214
  }
206
215
 
@@ -242,14 +251,17 @@ class CodexAdapter implements ToolAdapter {
242
251
 
243
252
  const proc = spawnCodex(args, cwd, userText);
244
253
 
245
- const onAbort = () => { proc.kill(); };
254
+ // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
255
+ // 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
256
+ // 会留下幽灵 node + codex.exe 继续烧 token、stream-state 永远停在 running。
257
+ // 因此 abort 与 finally 都必须用 killProcessTree 整棵进程树一起收尸。
258
+ const onAbort = () => { void killProcessTree(proc.pid); };
246
259
  signal?.addEventListener("abort", onAbort, { once: true });
247
260
 
248
261
  try {
249
262
  for await (const raw of readJsonLines(proc, signal)) {
250
263
  if (signal?.aborted) break;
251
264
 
252
- // 首次 prompt 时从 thread.started 事件学习 threadId
253
265
  if (
254
266
  isFirstPrompt &&
255
267
  raw.type === "thread.started" &&
@@ -265,7 +277,7 @@ class CodexAdapter implements ToolAdapter {
265
277
  }
266
278
  } finally {
267
279
  signal?.removeEventListener("abort", onAbort);
268
- proc.kill();
280
+ await killProcessTree(proc.pid);
269
281
  }
270
282
  }
271
283
 
@@ -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 消息行
@@ -288,13 +289,21 @@ async function* readJsonLines(
288
289
  signal?: AbortSignal,
289
290
  ): AsyncGenerator<CursorMessageLine> {
290
291
  const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
291
- for await (const line of rl) {
292
- if (signal?.aborted) break;
293
- const trimmed = line.trim();
294
- if (!trimmed) continue;
295
- try {
296
- yield JSON.parse(trimmed) as CursorMessageLine;
297
- } catch { /* 非 JSON 行静默跳过 */ }
292
+ // abort 时主动 close readline,避免等待 Windows 管道自然关闭(可能延迟数分钟)
293
+ const onAbort = () => { rl.close(); };
294
+ signal?.addEventListener("abort", onAbort, { once: true });
295
+ try {
296
+ for await (const line of rl) {
297
+ if (signal?.aborted) break;
298
+ const trimmed = line.trim();
299
+ if (!trimmed) continue;
300
+ try {
301
+ yield JSON.parse(trimmed) as CursorMessageLine;
302
+ } catch { /* 非 JSON 行静默跳过 */ }
303
+ }
304
+ } finally {
305
+ signal?.removeEventListener("abort", onAbort);
306
+ rl.close();
298
307
  }
299
308
  }
300
309
 
@@ -319,20 +328,16 @@ class CursorAdapter implements ToolAdapter {
319
328
  for await (const msg of readJsonLines(proc)) {
320
329
  if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
321
330
  const sessionId = msg.session_id;
322
- // 持久化 sessionId → { cwd, model }:getSessionInfo 后续依赖此映射,
323
- // 否则 Cursor 会话上的 /git、/status、/sessions 等命令将无法显示
324
- // 真实工作目录与真实模型。优先用 init 事件自报字段(cursor-agent
325
- // 内部权威),cwd 没有时退回调用方传入的 cwd 兜底。
326
331
  await this.metaStore
327
332
  .set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
328
333
  .catch(() => {});
329
334
  this.activeProcs.delete(proc);
330
- proc.kill();
335
+ await killProcessTree(proc.pid);
331
336
  return { sessionId };
332
337
  }
333
338
  }
334
339
 
335
- proc.kill();
340
+ await killProcessTree(proc.pid);
336
341
  this.activeProcs.delete(proc);
337
342
  throw new Error("No session ID in Cursor init event");
338
343
  }
@@ -346,16 +351,14 @@ class CursorAdapter implements ToolAdapter {
346
351
  const proc = spawnAgent(["--resume", sessionId], cwd, userText);
347
352
  this.activeProcs.add(proc);
348
353
 
349
- const onAbort = () => { proc.kill(); };
354
+ // codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
355
+ // 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
356
+ const onAbort = () => { void killProcessTree(proc.pid); };
350
357
  signal?.addEventListener("abort", onAbort, { once: true });
351
358
 
352
359
  try {
353
360
  for await (const raw of readJsonLines(proc, signal)) {
354
361
  if (signal?.aborted) break;
355
- // 自动学习:resume 流首条 init 事件若带 cwd / model,更新映射。
356
- // 这是为升级前创建的旧会话或映射文件意外丢失的情况兜底——
357
- // 用户向旧会话发一次消息后,/git、/status 等即可正常显示。
358
- // fire-and-forget:写失败不影响流式输出。
359
362
  if (
360
363
  raw.type === "system" &&
361
364
  raw.subtype === "init" &&
@@ -371,7 +374,7 @@ class CursorAdapter implements ToolAdapter {
371
374
  }
372
375
  } finally {
373
376
  signal?.removeEventListener("abort", onAbort);
374
- proc.kill();
377
+ await killProcessTree(proc.pid);
375
378
  this.activeProcs.delete(proc);
376
379
  }
377
380
  }
@@ -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