chatccc 0.2.93 → 0.2.95

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.93",
3
+ "version": "0.2.95",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -4,17 +4,63 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  // mock stream-state 以支持在测试中控制累积长度
7
- const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string; status?: "running" | "done" | "stopped" }>();
7
+ const mockStreamStates = new Map<string, {
8
+ accumulatedContent: string;
9
+ finalReply: string;
10
+ status?: "running" | "done" | "stopped" | "error";
11
+ turnCount?: number;
12
+ finalReplySentTurn?: number;
13
+ finalReplySentAt?: number;
14
+ }>();
8
15
  vi.mock("../stream-state.ts", () => ({
9
16
  readStreamState: async (sid: string) => {
10
17
  const state = mockStreamStates.get(sid);
11
18
  if (!state) return null;
12
- return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: state.status ?? "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
19
+ return {
20
+ sessionId: sid,
21
+ accumulatedContent: state.accumulatedContent,
22
+ finalReply: state.finalReply,
23
+ finalReplySentTurn: state.finalReplySentTurn,
24
+ finalReplySentAt: state.finalReplySentAt,
25
+ status: state.status ?? "running",
26
+ chunkCount: 0,
27
+ turnCount: state.turnCount ?? 0,
28
+ contextTokens: 0,
29
+ updatedAt: Date.now(),
30
+ cwd: "",
31
+ tool: "claude",
32
+ };
33
+ },
34
+ writeStreamState: async (state: {
35
+ sessionId: string;
36
+ accumulatedContent: string;
37
+ finalReply: string;
38
+ status?: "running" | "done" | "stopped" | "error";
39
+ turnCount?: number;
40
+ finalReplySentTurn?: number;
41
+ finalReplySentAt?: number;
42
+ }) => {
43
+ mockStreamStates.set(state.sessionId, {
44
+ accumulatedContent: state.accumulatedContent,
45
+ finalReply: state.finalReply,
46
+ status: state.status,
47
+ turnCount: state.turnCount,
48
+ finalReplySentTurn: state.finalReplySentTurn,
49
+ finalReplySentAt: state.finalReplySentAt,
50
+ });
13
51
  },
14
- writeStreamState: async () => {},
15
52
  createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
16
53
  sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
17
54
  }),
55
+ isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
56
+ markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
57
+ const state = mockStreamStates.get(sid);
58
+ if (!state) return;
59
+ if ((state.turnCount ?? 0) !== turnCount) return;
60
+ if ((state.status ?? "running") === "running") return;
61
+ state.finalReplySentTurn = turnCount;
62
+ state.finalReplySentAt = sentAt;
63
+ },
18
64
  fixStaleStreamStates: async () => {},
19
65
  }));
20
66
  import {
@@ -41,8 +87,13 @@ import {
41
87
  setSessionPlatform,
42
88
  recordChatPlatform,
43
89
  _getPlatformForChatForTest,
90
+ runAgentSession,
44
91
  startUnifiedDisplayLoop,
45
92
  stopUnifiedDisplayLoop,
93
+ _setProcessAliveForTest,
94
+ _resetProcessAliveForTest,
95
+ _setProcessMonitorIntervalForTest,
96
+ _resetProcessMonitorIntervalForTest,
46
97
  } from "../session.ts";
47
98
  import {
48
99
  activePrompts,
@@ -56,7 +107,7 @@ import {
56
107
  displayCards,
57
108
  } from "../session-chat-binding.ts";
58
109
  import type { AccumulatorState } from "../session.ts";
59
- import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
110
+ import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
60
111
  import type { PlatformAdapter } from "../platform-adapter.ts";
61
112
 
62
113
  // Helper to create a mock active session entry
@@ -185,6 +236,168 @@ describe("chat platform routing", () => {
185
236
  // processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
186
237
  // ---------------------------------------------------------------------------
187
238
 
239
+ describe("runAgentSession previous final delivery guard", () => {
240
+ let registryFile = "";
241
+ let toolsFile = "";
242
+
243
+ beforeEach(async () => {
244
+ resetState();
245
+ resetBindingState();
246
+ mockStreamStates.clear();
247
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-final-guard-"));
248
+ registryFile = join(dir, "session-registry.json");
249
+ toolsFile = join(dir, "session-tools.json");
250
+ _setSessionRegistryFileForTest(registryFile);
251
+ _setSessionToolsFileForTest(toolsFile);
252
+ _setAdapterForToolForTest(
253
+ "claude",
254
+ mockAdapter((sid) => ({ sessionId: sid, cwd: "/tmp" })),
255
+ );
256
+ });
257
+
258
+ afterEach(() => {
259
+ _resetSessionRegistryFileForTest();
260
+ _resetSessionToolsFileForTest();
261
+ _clearAdapterCacheForTest();
262
+ resetBindingState();
263
+ });
264
+
265
+ it("does not resend the previous terminal final when the same turn is already marked sent", async () => {
266
+ const platform = mockPlatform("feishu");
267
+ setSessionPlatform(platform);
268
+ bindChatToSession("sid-final", "chat-final");
269
+ recordLastActiveChat("sid-final", "chat-final");
270
+ sessionInfoMap.set("chat-final", {
271
+ sessionId: "sid-final",
272
+ turnCount: 1,
273
+ lastContextTokens: 0,
274
+ startTime: 0,
275
+ tool: "claude",
276
+ });
277
+ mockStreamStates.set("sid-final", {
278
+ accumulatedContent: "",
279
+ finalReply: "old final",
280
+ status: "done",
281
+ turnCount: 1,
282
+ finalReplySentTurn: 1,
283
+ });
284
+
285
+ await runAgentSession("sid-final", "next prompt", platform, "chat-final", Date.now(), "claude");
286
+
287
+ expect(platform.sendText).not.toHaveBeenCalledWith("chat-final", "old final");
288
+ });
289
+
290
+ it("resends the previous terminal final when there is no delivery marker", async () => {
291
+ const platform = mockPlatform("feishu");
292
+ setSessionPlatform(platform);
293
+ bindChatToSession("sid-unsent", "chat-unsent");
294
+ recordLastActiveChat("sid-unsent", "chat-unsent");
295
+ sessionInfoMap.set("chat-unsent", {
296
+ sessionId: "sid-unsent",
297
+ turnCount: 1,
298
+ lastContextTokens: 0,
299
+ startTime: 0,
300
+ tool: "claude",
301
+ });
302
+ mockStreamStates.set("sid-unsent", {
303
+ accumulatedContent: "",
304
+ finalReply: "old final",
305
+ status: "done",
306
+ turnCount: 1,
307
+ });
308
+
309
+ await runAgentSession("sid-unsent", "next prompt", platform, "chat-unsent", Date.now(), "claude");
310
+
311
+ expect(platform.sendText).toHaveBeenCalledWith("chat-unsent", "old final");
312
+ });
313
+ });
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
+
188
401
  describe("unified display loop WeChat delta", () => {
189
402
  beforeEach(() => {
190
403
  vi.useFakeTimers();
@@ -1004,6 +1217,7 @@ describe("switchChatBinding", () => {
1004
1217
  displayCards.set("chat-1", {
1005
1218
  cardId: "c1", sequence: 1, cardBusy: false,
1006
1219
  cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
1220
+ sessionId: "old-sid", turnCount: 5, dotCount: 0,
1007
1221
  });
1008
1222
 
1009
1223
  const result = await switchChatBinding({
@@ -1068,6 +1282,7 @@ describe("switchChatBinding", () => {
1068
1282
  const oldDisplay = {
1069
1283
  cardId: "c1", sequence: 1, cardBusy: false,
1070
1284
  cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
1285
+ sessionId: "old-sid", turnCount: 5, dotCount: 0,
1071
1286
  };
1072
1287
  displayCards.set("chat-1", oldDisplay);
1073
1288
 
@@ -15,10 +15,12 @@ vi.mock("../config.ts", async () => {
15
15
  });
16
16
 
17
17
  const {
18
- writeStreamState,
19
- readStreamState,
20
- createEmptyStreamState,
21
- STREAMS_DIR,
18
+ writeStreamState,
19
+ readStreamState,
20
+ createEmptyStreamState,
21
+ isFinalReplySentForTurn,
22
+ markFinalReplySent,
23
+ STREAMS_DIR,
22
24
  _setRenameForTest,
23
25
  _resetRenameForTest,
24
26
  } = await import("../stream-state.ts");
@@ -117,9 +119,36 @@ describe("writeStreamState — atomic rename", () => {
117
119
  const got = await readStreamState("never-existed");
118
120
  expect(got).toBeNull();
119
121
  });
120
- });
121
-
122
- 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", () => {
123
152
  it("生成的 state 字段正确且 status=running", () => {
124
153
  const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
125
154
  expect(s.sessionId).toBe("sid-X");
@@ -127,8 +156,9 @@ describe("createEmptyStreamState", () => {
127
156
  expect(s.tool).toBe("cursor");
128
157
  expect(s.turnCount).toBe(5);
129
158
  expect(s.status).toBe("running");
130
- expect(s.accumulatedContent).toBe("");
131
- expect(s.finalReply).toBe("");
132
- expect(s.chunkCount).toBe(0);
133
- });
134
- });
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
  }
@@ -65,6 +65,12 @@ export interface ActivePrompt {
65
65
  controller: AbortController;
66
66
  stopped: boolean;
67
67
  startTime: number;
68
+ /** Root PID for the CLI process currently serving this prompt, if the adapter exposes one. */
69
+ processPid?: number;
70
+ processMonitor?: ReturnType<typeof setInterval>;
71
+ /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
+ abnormalExit?: boolean;
73
+ abnormalExitNotified?: boolean;
68
74
  }
69
75
 
70
76
  export const activePrompts = new Map<string, ActivePrompt>();
@@ -206,6 +212,9 @@ export function consumeQueuedMessage(platform: PlatformAdapter, msg: QueuedMessa
206
212
  export function resetBindingState(): void {
207
213
  sessionChatsMap.clear();
208
214
  lastActiveChatMap.clear();
215
+ for (const prompt of activePrompts.values()) {
216
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
217
+ }
209
218
  activePrompts.clear();
210
219
  queuedMessages.clear();
211
220
  displayCards.clear();
package/src/session.ts CHANGED
@@ -25,6 +25,7 @@ import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
25
25
  import { logTrace } from "./trace.ts";
26
26
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
27
27
  import type { ToolAdapter } from "./adapters/adapter-interface.ts";
28
+ import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
28
29
  import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
29
30
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
30
31
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
@@ -37,7 +38,14 @@ function compressWechatDisplayText(text: string): string {
37
38
  if (lines.length <= 10) return text;
38
39
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
40
  }
40
- import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
41
+ import {
42
+ readStreamState,
43
+ writeStreamState,
44
+ createEmptyStreamState,
45
+ fixStaleStreamStates,
46
+ isFinalReplySentForTurn,
47
+ markFinalReplySent,
48
+ } from "./stream-state.ts";
41
49
  import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
42
50
  import {
43
51
  bindChatToSession,
@@ -59,6 +67,18 @@ import {
59
67
  consumeQueuePreservedChat,
60
68
  } from "./session-chat-binding.ts";
61
69
 
70
+ async function sendFinalReplyTextOnce(
71
+ platform: PlatformAdapter,
72
+ chatId: string,
73
+ sessionId: string,
74
+ turnCount: number,
75
+ text: string,
76
+ ): Promise<boolean> {
77
+ const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
78
+ if (sent) await markFinalReplySent(sessionId, turnCount);
79
+ return sent;
80
+ }
81
+
62
82
  // ---------------------------------------------------------------------------
63
83
  // Shared state (imported by index.ts)
64
84
  // ---------------------------------------------------------------------------
@@ -95,6 +115,110 @@ function platformForChat(chatId: string): PlatformAdapter | null {
95
115
  return chatPlatformMap.get(chatId) ?? platformRef;
96
116
  }
97
117
 
118
+ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
119
+ let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
120
+ let isProcessAliveImpl = (pid: number): boolean => {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+
129
+ export function _setProcessAliveForTest(impl: (pid: number) => boolean): void {
130
+ isProcessAliveImpl = impl;
131
+ }
132
+
133
+ export function _resetProcessAliveForTest(): void {
134
+ isProcessAliveImpl = (pid: number): boolean => {
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+ }
143
+
144
+ export function _setProcessMonitorIntervalForTest(ms: number): void {
145
+ processMonitorIntervalMs = ms;
146
+ }
147
+
148
+ export function _resetProcessMonitorIntervalForTest(): void {
149
+ processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
150
+ }
151
+
152
+ function clearPromptProcessMonitor(sessionId: string): void {
153
+ const prompt = activePrompts.get(sessionId);
154
+ if (!prompt?.processMonitor) return;
155
+ clearInterval(prompt.processMonitor);
156
+ prompt.processMonitor = undefined;
157
+ }
158
+
159
+ function formatTerminalHeader(status: "running" | "done" | "stopped" | "error"): {
160
+ title: string;
161
+ template?: string;
162
+ } {
163
+ if (status === "stopped") return { title: "已停止", template: "red" };
164
+ if (status === "error") return { title: "异常结束", template: "red" };
165
+ return { title: "完成" };
166
+ }
167
+
168
+ function turnFinalStatus(status: "running" | "done" | "stopped" | "error"): "done" | "stopped" {
169
+ return status === "stopped" || status === "error" ? "stopped" : "done";
170
+ }
171
+
172
+ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
173
+ const prompt = activePrompts.get(sessionId);
174
+ if (!prompt) return;
175
+ prompt.processPid = info.pid;
176
+ clearPromptProcessMonitor(sessionId);
177
+
178
+ const check = async () => {
179
+ const current = activePrompts.get(sessionId);
180
+ if (!current || current !== prompt) {
181
+ clearPromptProcessMonitor(sessionId);
182
+ return;
183
+ }
184
+ if (current.stopped || current.abnormalExit) return;
185
+ if (isProcessAliveImpl(info.pid)) return;
186
+
187
+ current.abnormalExit = true;
188
+ clearPromptProcessMonitor(sessionId);
189
+
190
+ const state = await readStreamState(sessionId);
191
+ if (state?.status === "running") {
192
+ await writeStreamState({
193
+ ...state,
194
+ status: "error",
195
+ updatedAt: Date.now(),
196
+ });
197
+ }
198
+
199
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
200
+ const p = chatId ? platformForChat(chatId) : null;
201
+ if (chatId && p && !current.abnormalExitNotified) {
202
+ current.abnormalExitNotified = true;
203
+ await p.sendText(
204
+ chatId,
205
+ `⚠️ 进程异常结束:session ${sessionId} 对应的 CLI 进程 PID ${info.pid} 已不存在,已按完成处理。若回复不完整,请重新发送上一条指令。`,
206
+ ).catch(() => {});
207
+ }
208
+
209
+ // 主动关闭 readline,让 runAgentSession 的 finally 落盘 error 终态并清理 activePrompts。
210
+ current.controller.abort();
211
+ };
212
+
213
+ const handle = setInterval(() => {
214
+ void check().catch((err) => {
215
+ console.warn(`[${ts()}] [PROCESS-MONITOR] check failed for ${sessionId}: ${(err as Error).message}`);
216
+ });
217
+ }, processMonitorIntervalMs);
218
+ handle.unref?.();
219
+ prompt.processMonitor = handle;
220
+ }
221
+
98
222
  export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | null {
99
223
  return platformForChat(chatId);
100
224
  }
@@ -170,6 +294,9 @@ export function resetState(): void {
170
294
  processedMessages.clear();
171
295
  lastMsgTimestamps.clear();
172
296
  chatPlatformMap.clear();
297
+ for (const prompt of activePrompts.values()) {
298
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
299
+ }
173
300
  activePrompts.clear();
174
301
  displayCards.clear();
175
302
  stopUnifiedDisplayLoop();
@@ -809,27 +936,38 @@ export async function runAgentSession(
809
936
  if (display && pp) {
810
937
  // 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
811
938
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
812
- const nextSeq = display.sequence + 1;
813
- const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
814
- const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
815
- const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
816
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
817
- await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
818
- displayCards.delete(displayChatId);
819
-
820
- // 持久化:标记上一轮所有卡片为终态
821
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
822
- finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
823
939
 
824
- if (prevState.finalReply) {
825
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
940
+ // 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
941
+ // terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
942
+ // 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
943
+ if (displayCards.get(displayChatId) !== display) {
944
+ const finalStatus = turnFinalStatus(prevState.status);
945
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
946
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
947
+ } else {
948
+ const nextSeq = display.sequence + 1;
949
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
950
+ const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
951
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
952
+ await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
953
+ // cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
954
+ const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
955
+ displayCards.delete(displayChatId);
956
+
957
+ // 持久化:标记上一轮所有卡片为终态
958
+ const finalStatus = turnFinalStatus(prevState.status);
959
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
960
+
961
+ if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
962
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
963
+ }
964
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
826
965
  }
827
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
828
- } else if (pp && prevState.finalReply) {
966
+ } else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
829
967
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
830
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
968
+ const finalStatus = turnFinalStatus(prevState.status);
831
969
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
832
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
970
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
833
971
  }
834
972
  // else: displayCards 无记录且无 finalReply → 无需处理
835
973
  }
@@ -897,7 +1035,14 @@ export async function runAgentSession(
897
1035
  const toolCallMap = new Map<string, { name: string; input: unknown }>();
898
1036
 
899
1037
  try {
900
- for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
1038
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1039
+ onProcessStart: (processInfo) => {
1040
+ startPromptProcessMonitor(sessionId, processInfo);
1041
+ },
1042
+ onProcessExit: () => {
1043
+ clearPromptProcessMonitor(sessionId);
1044
+ },
1045
+ })) {
901
1046
  for (const block of unifiedMsg.blocks) {
902
1047
  accumulateBlockContent(block, state, toolCallMap);
903
1048
 
@@ -940,13 +1085,15 @@ export async function runAgentSession(
940
1085
  // 标记 prompt 结束
941
1086
  const prompt = activePrompts.get(sessionId);
942
1087
  const wasStopped = prompt?.stopped ?? false;
1088
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1089
+ clearPromptProcessMonitor(sessionId);
943
1090
  activePrompts.delete(sessionId);
944
1091
 
945
1092
  // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
946
1093
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
947
1094
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
948
1095
  // 运行中并更新旧卡片,而不是新建卡片。
949
- const finalStatus = wasStopped ? "stopped" : "done";
1096
+ const finalStatus = wasAbnormalExit ? "error" : wasStopped ? "stopped" : "done";
950
1097
  const finalReply = pickFinalReply(state).trim();
951
1098
  await writeStreamState({
952
1099
  sessionId,
@@ -1008,6 +1155,23 @@ export async function runAgentSession(
1008
1155
  if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1009
1156
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1010
1157
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1158
+ } else if (wasAbnormalExit) {
1159
+ for (const cid of getChatsForSession(sessionId)) {
1160
+ const finfo = sessionInfoMap.get(cid);
1161
+ await recordSessionRegistry({
1162
+ chatId: cid,
1163
+ sessionId,
1164
+ tool,
1165
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1166
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1167
+ startTime: finfo?.startTime ?? now,
1168
+ running: false,
1169
+ });
1170
+ }
1171
+ const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1172
+ if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1173
+ console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1174
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1011
1175
  } else {
1012
1176
  for (const cid of getChatsForSession(sessionId)) {
1013
1177
  const finfo = sessionInfoMap.get(cid);
@@ -1092,24 +1256,45 @@ export function startUnifiedDisplayLoop(): void {
1092
1256
  replyDelta = state.finalReply;
1093
1257
  }
1094
1258
  const remaining = (accDelta + replyDelta).trim();
1259
+
1260
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1261
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1262
+ // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1263
+ // 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
1264
+ if (activePrompts.has(sessionId)) continue;
1265
+
1095
1266
  const tail = "━━━ 回答结束 ━━━";
1096
1267
  const finalMsg = remaining ? remaining + "\n" + tail : tail;
1097
- await p.sendText(chatId, finalMsg).catch(() => {});
1268
+ if (!isFinalReplySentForTurn(state)) {
1269
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1270
+ }
1098
1271
  displayCards.delete(chatId);
1099
1272
  } else {
1100
1273
  // 发送最终结果(卡片平台)
1101
1274
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1102
1275
  const nextSeq = display.sequence + 1;
1103
- const headerTitle = state.status === "stopped" ? "已停止" : "完成";
1104
- const headerTemplate = state.status === "stopped" ? "red" : undefined;
1276
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1105
1277
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1106
1278
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1107
1279
  await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
1108
- const finalSt = state.status === "stopped" ? "stopped" : "done";
1280
+
1281
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1282
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1283
+ // 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
1284
+ // 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
1285
+ if (activePrompts.has(sessionId)) {
1286
+ display.lastSentAccLen = state.accumulatedContent.length;
1287
+ display.lastSentFinalReply = state.finalReply;
1288
+ continue;
1289
+ }
1290
+
1291
+ const finalSt = turnFinalStatus(state.status);
1109
1292
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1110
1293
  displayCards.delete(chatId);
1111
1294
  if (state.finalReply) {
1112
- await p.sendText(chatId, state.finalReply).catch(() => {});
1295
+ if (!isFinalReplySentForTurn(state)) {
1296
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
1297
+ }
1113
1298
  } else if (state.accumulatedContent.trim()) {
1114
1299
  const short = truncateContent(state.accumulatedContent, 30, 4000);
1115
1300
  await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
@@ -1165,7 +1350,7 @@ export function startUnifiedDisplayLoop(): void {
1165
1350
  try {
1166
1351
  const oldSeqBase = display.sequence;
1167
1352
  const oldContent = state.accumulatedContent + state.finalReply;
1168
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "完成" });
1353
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1169
1354
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
1170
1355
  markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
1171
1356
  const newCardId = await p.cardCreate(buildProgressCard(
@@ -1290,6 +1475,7 @@ export function stopSession(sessionId: string): boolean {
1290
1475
  const prompt = activePrompts.get(sessionId);
1291
1476
  if (!prompt) return false;
1292
1477
  prompt.stopped = true;
1478
+ clearPromptProcessMonitor(sessionId);
1293
1479
  cancelQueuedMessage(sessionId);
1294
1480
  prompt.controller.abort();
1295
1481
  console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
@@ -1381,7 +1567,8 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
1381
1567
  const info = sessionInfoMap.get(chatId);
1382
1568
  if (!info) return null;
1383
1569
 
1384
- const isActive = activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped);
1570
+ const activePrompt = activePrompts.get(info.sessionId);
1571
+ const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
1385
1572
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
1386
1573
 
1387
1574
  const registry = await loadSessionRegistry();
@@ -1456,7 +1643,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
1456
1643
  chatId: info.chatId,
1457
1644
  sessionId: info.sessionId,
1458
1645
  chatName: info.chatName || "",
1459
- active: activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped),
1646
+ active: !!activePrompts.get(info.sessionId) &&
1647
+ !activePrompts.get(info.sessionId)?.stopped &&
1648
+ !activePrompts.get(info.sessionId)?.abnormalExit,
1460
1649
  turnCount: info.turnCount,
1461
1650
  startTime: info.startTime,
1462
1651
  model,
@@ -9,14 +9,17 @@ import { USER_DATA_DIR, ts } from "./config.ts";
9
9
 
10
10
  export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
11
11
 
12
- export interface StreamState {
13
- sessionId: string;
14
- status: "running" | "done" | "stopped" | "error";
15
- accumulatedContent: string;
16
- finalReply: string;
17
- chunkCount: number;
18
- turnCount: number;
19
- contextTokens: number;
12
+ export interface StreamState {
13
+ sessionId: string;
14
+ status: "running" | "done" | "stopped" | "error";
15
+ accumulatedContent: string;
16
+ finalReply: string;
17
+ /** The turn whose terminal text reply has already been delivered to IM. */
18
+ finalReplySentTurn?: number;
19
+ finalReplySentAt?: number;
20
+ chunkCount: number;
21
+ turnCount: number;
22
+ contextTokens: number;
20
23
  updatedAt: number;
21
24
  cwd: string;
22
25
  tool: string;
@@ -71,7 +74,7 @@ export function _resetRenameForTest(): void {
71
74
  * 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
72
75
  * 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
73
76
  */
74
- export async function writeStreamState(state: StreamState): Promise<void> {
77
+ export async function writeStreamState(state: StreamState): Promise<void> {
75
78
  const filePath = getStreamStatePath(state.sessionId);
76
79
  const payload = JSON.stringify(state, null, 2);
77
80
  try {
@@ -95,10 +98,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
95
98
  } catch (err) {
96
99
  console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
97
100
  }
98
- }
99
-
100
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
101
- return {
101
+ }
102
+
103
+ export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
104
+ return state.finalReplySentTurn === state.turnCount;
105
+ }
106
+
107
+ export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
108
+ const state = await readStreamState(sessionId);
109
+ if (!state) return;
110
+ if (state.turnCount !== turnCount) return;
111
+ if (state.status === "running") return;
112
+
113
+ state.finalReplySentTurn = turnCount;
114
+ state.finalReplySentAt = sentAt;
115
+ state.updatedAt = Date.now();
116
+ await writeStreamState(state);
117
+ }
118
+
119
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
120
+ return {
102
121
  sessionId,
103
122
  status: "running",
104
123
  accumulatedContent: "",
@@ -138,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
138
157
  } catch (err) {
139
158
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
140
159
  }
141
- }
160
+ }