chatccc 0.2.283 → 0.2.284

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/README.md CHANGED
@@ -449,6 +449,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
449
449
 
450
450
  ChatCCC 的内部重启和更新使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。
451
451
 
452
+ Codex 和 Cursor 在 Linux/macOS 下使用独立进程组。停止时先显示“正在停止”,待进程组内后代退出后再显示“已停止”;仅外层 shell 退出不算清理完成。重复停止请求合并处理,清理失败会保留会话进程占用保护并报告“Agent 停止未完成”,后续请求必须先完成清理才能启动新 Agent。Windows 继续使用 `taskkill /T /F`,命令失败或超时不会当作成功。
453
+
452
454
  飞书接收长连接启用 15 秒握手超时和 30 秒心跳应答超时;首次启动最多等待 45 秒真实连接确认后才提示就绪。运行期间连接持续异常 90 秒时,会关闭并重新建立接收连接,保留会话、消息去重和正在执行的任务。没有用户消息不会触发重连。连接状态与恢复记录写入运行日志和 `startup-trace.log`(`FEISHU-CONNECTION` / `feishu-connection`)。卡片更新遇到网络失败或序号冲突时不会当成送达成功,错误通知保留文本兜底;TLS 断线会明确显示为网络连接失败,已中断的 Agent 任务不会因此自动重放。
453
455
 
454
456
  > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
@@ -8,12 +8,12 @@
8
8
  // =============================================================================
9
9
  import { spawn } from "node:child_process";
10
10
  import { createTurnCompletion } from "./turn-completion.js";
11
+ import { cliProcessOptions, ensureCliSessionReleased, ownCliProcess } from "./managed-cli-process.js";
11
12
  import { existsSync, readFileSync } from "node:fs";
12
13
  import { join } from "node:path";
13
14
  import { randomUUID } from "node:crypto";
14
15
  import { parseUserCommand } from "./adapter-interface.js";
15
16
  import { defaultCodexSessionMetaStore, } from "./codex-session-meta-store.js";
16
- import { killProcessTree } from "./proc-tree-kill.js";
17
17
  import { config, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
18
18
  import { createRawStreamLog, } from "./raw-stream-log.js";
19
19
  import { readJsonLinesWithBadJsonIdleWatchdog } from "./jsonl-stream.js";
@@ -159,6 +159,7 @@ function spawnCodex(args, cwd, stdinText, modelOverride, effortOverride, fastMod
159
159
  stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
160
160
  windowsHide: true,
161
161
  shell: true,
162
+ ...cliProcessOptions(),
162
163
  });
163
164
  let stderr = "";
164
165
  let exitCode = null;
@@ -226,6 +227,9 @@ class CodexAdapter {
226
227
  return { sessionId };
227
228
  }
228
229
  async *prompt(sessionId, userText, cwd, signal, options) {
230
+ if (signal?.aborted)
231
+ return;
232
+ await ensureCliSessionReleased(sessionId);
229
233
  let meta = await this.metaStore.get(sessionId);
230
234
  const threadId = meta?.threadId;
231
235
  const isFirstPrompt = !threadId;
@@ -240,6 +244,7 @@ class CodexAdapter {
240
244
  : [...baseArgs, "resume", threadId, "-"];
241
245
  const handle = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride, this.fastModeOverride);
242
246
  const proc = handle.proc;
247
+ const ownership = ownCliProcess(sessionId, proc.pid);
243
248
  if (proc.pid !== undefined)
244
249
  options?.onProcessStart?.({ pid: proc.pid });
245
250
  const rawLogConfig = config.rawStreamLogs.codex;
@@ -262,7 +267,7 @@ class CodexAdapter {
262
267
  // 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
263
268
  // 会留下幽灵 node + codex.exe 继续烧 token、stream-state 永远停在 running。
264
269
  // 因此 abort 与 finally 都必须用 killProcessTree 整棵进程树一起收尸。
265
- const onAbort = () => { void killProcessTree(proc.pid); };
270
+ const onAbort = () => { void ownership.stop().catch(() => { }); };
266
271
  signal?.addEventListener("abort", onAbort, { once: true });
267
272
  let completed = false;
268
273
  const completion = createTurnCompletion("Codex");
@@ -293,10 +298,16 @@ class CodexAdapter {
293
298
  }
294
299
  finally {
295
300
  signal?.removeEventListener("abort", onAbort);
296
- await killProcessTree(proc.pid);
297
- await rawLog?.close({ keep: rawLogConfig.keepCompleted || signal?.aborted === true || !completed });
298
- if (proc.pid !== undefined)
299
- options?.onProcessExit?.({ pid: proc.pid });
301
+ let released = false;
302
+ try {
303
+ await ownership.stop();
304
+ released = true;
305
+ }
306
+ finally {
307
+ await rawLog?.close({ keep: !released || rawLogConfig.keepCompleted || signal?.aborted === true || !completed });
308
+ if (released && proc.pid !== undefined)
309
+ options?.onProcessExit?.({ pid: proc.pid });
310
+ }
300
311
  }
301
312
  }
302
313
  async getSessionInfo(sessionId) {
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { parseUserCommand } from "./adapter-interface.js";
11
11
  import { createTurnCompletion } from "./turn-completion.js";
12
+ import { cliProcessOptions, ensureCliSessionReleased, ownCliProcess } from "./managed-cli-process.js";
12
13
  import { config, CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
13
14
  import { defaultCursorSessionMetaStore, } from "./cursor-session-meta-store.js";
14
15
  import { killProcessTree } from "./proc-tree-kill.js";
@@ -288,6 +289,7 @@ function spawnAgent(extraArgs, cwd, stdinText, modelOverride, mode, spawnImpl =
288
289
  stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
289
290
  windowsHide: true,
290
291
  shell: true,
292
+ ...cliProcessOptions(),
291
293
  });
292
294
  console.log(`[Cursor debug] spawn: cmd=${CURSOR_AGENT_COMMAND}, args=[${allArgs.join(", ")}], cwd=${cwd ?? "(none)"}, stdinLen=${stdinText?.length ?? 0}, pid=${proc.pid}`);
293
295
  // 收集 stderr,子进程异常退出时输出到日志,方便排查静默失败
@@ -411,15 +413,20 @@ class CursorAdapter {
411
413
  }
412
414
  finally {
413
415
  signal?.removeEventListener("abort", onAbort);
414
- await killProcessTree(proc.pid);
416
+ if (await killProcessTree(proc.pid) === false)
417
+ throw new Error(`Cursor 初始化进程未确认退出(PID ${proc.pid})`);
415
418
  this.activeProcs.delete(proc);
416
419
  }
417
420
  }
418
421
  async *prompt(sessionId, userText, cwd, signal, options) {
422
+ if (signal?.aborted)
423
+ return;
424
+ await ensureCliSessionReleased(sessionId);
419
425
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
420
426
  const cmd = parseUserCommand(userText);
421
427
  const handle = spawnAgent(["--resume", sessionId], cwd, buildCursorPromptText(userText), this.modelOverride, cmd.mode ?? undefined, this.spawnImpl);
422
428
  const proc = handle.proc;
429
+ const ownership = ownCliProcess(sessionId, proc.pid);
423
430
  this.activeProcs.add(proc);
424
431
  if (proc.pid !== undefined)
425
432
  options?.onProcessStart?.({ pid: proc.pid });
@@ -441,7 +448,7 @@ class CursorAdapter {
441
448
  }
442
449
  // 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
443
450
  // 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
444
- const onAbort = () => { void killProcessTree(proc.pid); };
451
+ const onAbort = () => { void ownership.stop().catch(() => { }); };
445
452
  signal?.addEventListener("abort", onAbort, { once: true });
446
453
  let sawResult = false;
447
454
  const completion = createTurnCompletion("Cursor");
@@ -469,7 +476,7 @@ class CursorAdapter {
469
476
  if (!normalized?.isFinalResponse)
470
477
  yield { type: "assistant", blocks: [], isFinalResponse: true };
471
478
  sawResult = true;
472
- void killProcessTree(proc.pid);
479
+ void ownership.stop().catch(() => { });
473
480
  break;
474
481
  }
475
482
  }
@@ -492,11 +499,17 @@ class CursorAdapter {
492
499
  }
493
500
  finally {
494
501
  signal?.removeEventListener("abort", onAbort);
495
- await killProcessTree(proc.pid);
496
- await rawLog?.close({ keep: rawLogConfig.keepCompleted || signal?.aborted === true || !sawResult });
497
- this.activeProcs.delete(proc);
498
- if (proc.pid !== undefined)
499
- options?.onProcessExit?.({ pid: proc.pid });
502
+ let released = false;
503
+ try {
504
+ await ownership.stop();
505
+ released = true;
506
+ }
507
+ finally {
508
+ await rawLog?.close({ keep: !released || rawLogConfig.keepCompleted || signal?.aborted === true || !sawResult });
509
+ this.activeProcs.delete(proc);
510
+ if (released && proc.pid !== undefined)
511
+ options?.onProcessExit?.({ pid: proc.pid });
512
+ }
500
513
  console.log(`[Cursor debug] prompt end: sessionId=${sessionId}, signalAborted=${signal?.aborted ?? false}`);
501
514
  }
502
515
  }
@@ -0,0 +1,43 @@
1
+ import { killProcessTree } from "./proc-tree-kill.js";
2
+ export function cliProcessOptions(platform = process.platform) {
3
+ return { detached: platform !== "win32" };
4
+ }
5
+ const owners = new Map();
6
+ export class ProcessCleanupError extends Error {
7
+ code = "PROCESS_CLEANUP_FAILED";
8
+ }
9
+ /** A new adapter instance must not bypass a previous failed cleanup. */
10
+ export async function ensureCliSessionReleased(sessionId) {
11
+ const owner = owners.get(sessionId);
12
+ if (!owner)
13
+ return;
14
+ if (!owner.stopping)
15
+ throw new Error("该会话的 Agent 仍在执行,暂不能启动另一个进程");
16
+ await owner.stop();
17
+ }
18
+ export function ownCliProcess(sessionId, pid) {
19
+ let pending;
20
+ let finished = false;
21
+ const owner = {
22
+ stopping: false,
23
+ stop() {
24
+ if (finished)
25
+ return Promise.resolve();
26
+ if (pending)
27
+ return pending;
28
+ owner.stopping = true;
29
+ pending = killProcessTree(pid).then(ok => {
30
+ if (ok === false)
31
+ throw new ProcessCleanupError(`Agent 进程未确认退出(PID ${pid}),会话仍受保护;请重试停止或检查残留进程后再继续。`);
32
+ finished = true;
33
+ if (owners.get(sessionId) === owner)
34
+ owners.delete(sessionId);
35
+ }).finally(() => { pending = undefined; });
36
+ return pending;
37
+ },
38
+ };
39
+ // Test/non-process adapters can have no PID; they own no OS resource.
40
+ if (pid !== undefined)
41
+ owners.set(sessionId, owner);
42
+ return owner;
43
+ }
@@ -1,94 +1,95 @@
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
- import { spawn } from "node:child_process";
22
- /** 异步杀掉以 pid 为根的整棵进程树。
23
- *
24
- * 设计目标:永不抛错、永不阻塞调用者。
25
- * - pid 不存在、参数缺失 → 静默返回
26
- * - 子进程 spawn 失败 → console.warn 但不 reject
27
- * - Windows 上 taskkill 异步执行,不阻塞 event loop
28
- *
29
- * 调用方约定:返回的 Promise 在 kill 命令发出后立即 resolve。
30
- * 真正的进程退出由 OS 异步完成,调用方如果需要确认"已死透",应自己再轮询
31
- * `process.kill(pid, 0)`。
32
- */
33
- export async function killProcessTree(pid) {
34
- if (pid == null || !Number.isFinite(pid) || pid <= 0)
35
- return;
36
- if (process.platform === "win32") {
37
- await killWindowsTree(pid);
38
- return;
1
+ // Process groups are created by cliProcessOptions on POSIX. Never target a
2
+ // shared parent group. A false result means callers must retain ownership.
3
+ import { spawn, execFile } from "node:child_process";
4
+ const pending = new Map();
5
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
+ function alive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ return error.code !== "ESRCH";
39
13
  }
40
- await killPosixTree(pid);
41
14
  }
42
- // ---------------------------------------------------------------------------
43
- // Windows: taskkill /T /F
44
- // ---------------------------------------------------------------------------
45
- function killWindowsTree(pid) {
46
- return new Promise((resolve) => {
47
- let resolved = false;
48
- const done = () => {
49
- if (resolved)
15
+ /** Confirm the group, not only its leader: descendants may outlive the shell. */
16
+ export async function terminatePosixGroup(pid, deps) {
17
+ const send = (target, signal) => {
18
+ try {
19
+ deps.signal(target, signal);
20
+ }
21
+ catch { /* confirmation below decides success */ }
22
+ };
23
+ send(-pid, "SIGTERM");
24
+ send(pid, "SIGTERM");
25
+ for (let attempt = 0; attempt < 10; attempt++) {
26
+ if (!await deps.hasLiveMembers(pid))
27
+ return true;
28
+ await deps.sleep(100);
29
+ }
30
+ send(-pid, "SIGKILL");
31
+ send(pid, "SIGKILL");
32
+ for (let attempt = 0; attempt < 40; attempt++) {
33
+ if (!await deps.hasLiveMembers(pid))
34
+ return true;
35
+ await deps.sleep(100);
36
+ }
37
+ return !await deps.hasLiveMembers(pid);
38
+ }
39
+ function posixMembersAlive(pid) {
40
+ return new Promise(resolve => {
41
+ execFile("ps", ["-eo", "pid=,pgid=,stat="], { timeout: 2_000, maxBuffer: 4 * 1024 * 1024 }, (error, output) => {
42
+ if (error) {
43
+ resolve(alive(-pid) || alive(pid));
50
44
  return;
51
- resolved = true;
52
- resolve();
53
- };
45
+ }
46
+ // Zombies have already released files/locks; waiting for init to reap
47
+ // them would otherwise block containers with a non-reaping PID 1.
48
+ resolve(output.split("\n").some(line => {
49
+ const [id, group, state] = line.trim().split(/\s+/);
50
+ return (Number(id) === pid || Number(group) === pid) && !!state && !/^[ZX]/.test(state);
51
+ }));
52
+ });
53
+ });
54
+ }
55
+ function killWindowsTree(pid) {
56
+ if (!alive(pid))
57
+ return Promise.resolve(true);
58
+ return new Promise(resolve => {
59
+ let settled = false;
60
+ let timer;
61
+ const done = (ok) => { if (!settled) {
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ resolve(ok);
65
+ } };
66
+ timer = setTimeout(() => done(false), 5_000);
54
67
  try {
55
- const proc = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
56
- stdio: "ignore",
57
- windowsHide: true,
58
- // taskkill 本身很快(<200ms),不需要 detached
59
- });
60
- proc.once("error", (err) => {
61
- console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${err.message}`);
62
- done();
63
- });
64
- proc.once("close", () => { done(); });
65
- // 兜底超时:3 秒后强制 resolve,避免极端情况下 hang 住调用方
66
- setTimeout(done, 3000).unref();
68
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
69
+ killer.once("error", () => done(false));
70
+ killer.once("close", (code) => done(code === 0 && !alive(pid)));
67
71
  }
68
- catch (err) {
69
- console.warn(`[killProcessTree] taskkill failed for pid=${pid}: ${err.message}`);
70
- done();
72
+ catch {
73
+ done(false);
71
74
  }
72
75
  });
73
76
  }
74
- // ---------------------------------------------------------------------------
75
- // POSIX: 优先按 process group 杀,回退到按 pid
76
- // ---------------------------------------------------------------------------
77
- async function killPosixTree(pid) {
78
- // 第一次尝试:按 process group SIGTERM。要求 spawn detached:true。
79
- trySignal(-pid, "SIGTERM");
80
- trySignal(pid, "SIGTERM");
81
- // 给进程 1 秒优雅退出机会
82
- await new Promise((r) => setTimeout(r, 1000));
83
- // 兜底:SIGKILL
84
- trySignal(-pid, "SIGKILL");
85
- trySignal(pid, "SIGKILL");
86
- }
87
- function trySignal(target, signal) {
88
- try {
89
- process.kill(target, signal);
90
- }
91
- catch {
92
- // 进程已不存在或权限不足,忽略
93
- }
77
+ /** Coalesce abort/watchdog/finally calls; success requires observed termination. */
78
+ export function killProcessTree(pid) {
79
+ if (pid == null)
80
+ return Promise.resolve(true);
81
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || pid === process.ppid)
82
+ return Promise.resolve(false);
83
+ const existing = pending.get(pid);
84
+ if (existing)
85
+ return existing;
86
+ const operation = (process.platform === "win32" ? killWindowsTree(pid) : terminatePosixGroup(pid, {
87
+ signal: (target, signal) => { process.kill(target, signal); }, hasLiveMembers: posixMembersAlive, sleep: delay,
88
+ })).catch(() => false).then(ok => {
89
+ if (!ok)
90
+ console.error(`[killProcessTree] cleanup not confirmed for PID ${pid}`);
91
+ return ok;
92
+ }).finally(() => pending.delete(pid));
93
+ pending.set(pid, operation);
94
+ return operation;
94
95
  }
@@ -1199,6 +1199,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1199
1199
  const FILE_WRITE_INTERVAL_MS = 2000;
1200
1200
  const toolCallMap = new Map();
1201
1201
  let streamErrored = false;
1202
+ let cleanupFailed = false;
1202
1203
  let streamTerminalError;
1203
1204
  let runOutcome = "error";
1204
1205
  const responseStallDetectionEnabled = adapter.responseStallDetectionEnabled !== false;
@@ -1369,6 +1370,9 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1369
1370
  }
1370
1371
  catch (streamErr) {
1371
1372
  streamErrored = true;
1373
+ cleanupFailed = streamErr?.code === "PROCESS_CLEANUP_FAILED";
1374
+ if (cleanupFailed)
1375
+ cancelAutoRecoveryReservation(sessionId);
1372
1376
  streamTerminalError = classifyTerminalError(streamErr);
1373
1377
  console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${streamErr.message}`);
1374
1378
  }
@@ -1407,7 +1411,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1407
1411
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1408
1412
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1409
1413
  // 运行中并更新旧卡片,而不是新建卡片。
1410
- const finalStatus = completedAtTimeoutBoundary
1414
+ const finalStatus = cleanupFailed ? "error" : completedAtTimeoutBoundary
1411
1415
  ? "done"
1412
1416
  : wasAutoEnded
1413
1417
  ? "auto_ended"
@@ -1468,7 +1472,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1468
1472
  });
1469
1473
  // display loop 下一轮会读到最终状态并发送消息
1470
1474
  let autoRecoveryTarget;
1471
- if (wasStopped) {
1475
+ if (wasStopped && !cleanupFailed) {
1472
1476
  for (const cid of finalizationChatIds) {
1473
1477
  const finfo = sessionInfoMap.get(cid);
1474
1478
  await recordSessionRegistry({
@@ -1490,7 +1494,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1490
1494
  if (tid)
1491
1495
  logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1492
1496
  }
1493
- else if (wasAutoEnded) {
1497
+ else if (wasAutoEnded && !cleanupFailed) {
1494
1498
  for (const cid of finalizationChatIds) {
1495
1499
  const finfo = sessionInfoMap.get(cid);
1496
1500
  await recordSessionRegistry({
@@ -1874,7 +1878,9 @@ export function startUnifiedDisplayLoop() {
1874
1878
  displayCards.delete(chatId);
1875
1879
  continue;
1876
1880
  }
1877
- const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
1881
+ const activityHeaderTitle = activePrompts.get(sessionId)?.stopped
1882
+ ? "正在停止 · 等待 Agent 退出"
1883
+ : formatAgentActivityTitle(state.activity, Date.now());
1878
1884
  // 卡片轮转
1879
1885
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1880
1886
  display.cardBusy = true;
@@ -2027,11 +2033,8 @@ export function stopUnifiedDisplayLoop() {
2027
2033
  // 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
2028
2034
  // 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
2029
2035
  //
2030
- // 2) 立刻 fire-and-forget stream-state stopped,不依赖 runAgentSession
2031
- // finally。原因:generator 自然结束依赖子进程 stdout 关闭,killProcessTree
2032
- // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
2033
- // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
2034
- // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
2036
+ // 2) 保留 running 与会话占用,界面先显示正在停止;只有 adapter finally 确认
2037
+ // 进程退出后才由 runAgentSession stopped,清理失败则显示错误。
2035
2038
  export function stopSession(sessionId) {
2036
2039
  // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2037
2040
  // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
@@ -2064,26 +2067,8 @@ export function stopSession(sessionId) {
2064
2067
  }
2065
2068
  prompt.controller.abort();
2066
2069
  console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2067
- // fire-and-forget:立刻把 stream-state.status 改成 stopped,
2068
- // display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
2069
- void (async () => {
2070
- try {
2071
- const current = await readStreamState(sessionId);
2072
- if (!current)
2073
- return;
2074
- // 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
2075
- if (current.status !== "running")
2076
- return;
2077
- await writeStreamState({
2078
- ...current,
2079
- status: "stopped",
2080
- updatedAt: Date.now(),
2081
- });
2082
- }
2083
- catch (err) {
2084
- console.warn(`[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${err.message}`);
2085
- }
2086
- })();
2070
+ // Keep durable state running until the adapter confirms process cleanup.
2071
+ // The display loop renders the in-memory stop request as "正在停止".
2087
2072
  return true;
2088
2073
  }
2089
2074
  // ---------------------------------------------------------------------------
@@ -2148,7 +2133,7 @@ export async function getSessionStatus(chatId) {
2148
2133
  if (!info)
2149
2134
  return null;
2150
2135
  const activePrompt = activePrompts.get(info.sessionId);
2151
- const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
2136
+ const isActive = !!activePrompt;
2152
2137
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
2153
2138
  const registry = await loadSessionRegistry();
2154
2139
  const chatName = registry[chatId]?.chatName ?? "";
@@ -2217,9 +2202,7 @@ export async function getAllSessionsStatus(options = {}) {
2217
2202
  displayTitle: info.displayTitle || "",
2218
2203
  pinned: info.pinned ?? false,
2219
2204
  ...(info.archivedAt ? { archivedAt: info.archivedAt } : {}),
2220
- active: !!activePrompts.get(info.sessionId) &&
2221
- !activePrompts.get(info.sessionId)?.stopped &&
2222
- !activePrompts.get(info.sessionId)?.abnormalExit,
2205
+ active: activePrompts.has(info.sessionId),
2223
2206
  turnCount: info.turnCount,
2224
2207
  startTime: info.startTime,
2225
2208
  model,
@@ -35,6 +35,9 @@ function formatSeconds(milliseconds) {
35
35
  export function classifyTerminalError(error, occurredAt = Date.now()) {
36
36
  const raw = errorMessage(error);
37
37
  const lower = raw.toLowerCase();
38
+ if (error?.code === "PROCESS_CLEANUP_FAILED") {
39
+ return { kind: "process", title: "Agent 停止未完成", message: sanitizeTerminalErrorDetail(raw), occurredAt };
40
+ }
38
41
  const attempts = parsePositiveInt(raw, /\bafter\s+(\d+)\s+attempts?\b/i);
39
42
  const timeoutMs = parsePositiveInt(raw, /\btimeout\s*:\s*(\d+)\s*ms\b/i);
40
43
  if (/\b429\b|rate[ _-]?limit|too many requests|resource_exhausted/.test(lower)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.283",
3
+ "version": "0.2.284",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",