chatccc 0.2.222 → 0.2.224

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/src/session.ts CHANGED
@@ -2,9 +2,9 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
4
  import {
5
- CLAUDE_API_KEY,
6
- CLAUDE_BASE_URL,
7
- CLAUDE_MAX_TURN,
5
+ CLAUDE_API_KEY,
6
+ CLAUDE_BASE_URL,
7
+ CLAUDE_MAX_TURN,
8
8
  CLAUDE_MODEL,
9
9
  CLAUDE_SUBAGENT_MODEL,
10
10
  CHATCCC_PORT,
@@ -14,41 +14,42 @@ import {
14
14
  addRecentDir,
15
15
  anthropicConfigDisplay,
16
16
  config,
17
- fileLog,
18
- getDefaultCwd,
19
- getDefaultEffortForTool,
20
- isAnthropicConfigEmpty,
21
- toolDisplayName,
22
- ts,
23
- } from "./config.ts";
24
- import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
25
- import {
26
- createAgentActivityTracker,
27
- formatAgentActivityTitle,
28
- updateAgentActivity,
29
- } from "./agent-activity.ts";
30
- import type { AgentActivityKind } from "./agent-activity.ts";
17
+ fileLog,
18
+ getDefaultCwd,
19
+ getDefaultEffortForTool,
20
+ isAnthropicConfigEmpty,
21
+ toolDisplayName,
22
+ ts,
23
+ } from "./config.ts";
24
+ import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
25
+ import { progressView } from "./progress/view.ts";
26
+ import {
27
+ createAgentActivityTracker,
28
+ formatAgentActivityTitle,
29
+ updateAgentActivity,
30
+ } from "./agent-activity.ts";
31
+ import type { AgentActivityKind } from "./agent-activity.ts";
31
32
  import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
32
33
  import { logTrace } from "./trace.ts";
33
34
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
34
35
  import type { ToolAdapter } from "./adapters/adapter-interface.ts";
35
36
  import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
36
- import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
37
- import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
38
- import { createCodexAdapter } from "./adapters/codex-adapter.ts";
39
- import { createCccAdapter } from "./adapters/ccc-adapter.ts";
40
- import { killProcessTree } from "./adapters/proc-tree-kill.ts";
41
- import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
42
- import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
43
- import type { PlatformAdapter } from "./platform-adapter.ts";
44
- import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
45
- import {
46
- MAX_PROCESSED,
47
- clearFeishuMessageLedgerMemory,
48
- processedMessages,
49
- } from "./feishu-message-ingress.ts";
50
-
51
- export { MAX_PROCESSED, processedMessages };
37
+ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
38
+ import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
39
+ import { createCodexAdapter } from "./adapters/codex-adapter.ts";
40
+ import { createCccAdapter } from "./adapters/ccc-adapter.ts";
41
+ import { killProcessTree } from "./adapters/proc-tree-kill.ts";
42
+ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
43
+ import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
44
+ import type { PlatformAdapter } from "./platform-adapter.ts";
45
+ import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
46
+ import {
47
+ MAX_PROCESSED,
48
+ clearFeishuMessageLedgerMemory,
49
+ processedMessages,
50
+ } from "./feishu-message-ingress.ts";
51
+
52
+ export { MAX_PROCESSED, processedMessages };
52
53
 
53
54
  // 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
54
55
  function compressWechatDisplayText(text: string): string {
@@ -80,16 +81,16 @@ import {
80
81
  pickDisplayChat,
81
82
  dequeueMessage,
82
83
  consumeQueuedMessage,
83
- cancelQueuedMessage,
84
- setQueuePreservedChat,
85
- consumeQueuePreservedChat,
86
- markSessionFinalizing,
87
- clearSessionFinalizing,
88
- reserveAutoRecovery,
89
- consumeAutoRecoveryReservation,
90
- cancelAutoRecoveryReservation,
91
- hasAutoRecoveryReservation,
92
- } from "./session-chat-binding.ts";
84
+ cancelQueuedMessage,
85
+ setQueuePreservedChat,
86
+ consumeQueuePreservedChat,
87
+ markSessionFinalizing,
88
+ clearSessionFinalizing,
89
+ reserveAutoRecovery,
90
+ consumeAutoRecoveryReservation,
91
+ cancelAutoRecoveryReservation,
92
+ hasAutoRecoveryReservation,
93
+ } from "./session-chat-binding.ts";
93
94
 
94
95
  async function sendFinalReplyTextOnce(
95
96
  platform: PlatformAdapter,
@@ -103,19 +104,19 @@ async function sendFinalReplyTextOnce(
103
104
  return sent;
104
105
  }
105
106
 
106
- async function createVisibleProgressCard(
107
+ async function createVisibleProgressCard(
107
108
  platform: PlatformAdapter,
108
109
  chatId: string,
109
110
  sessionId: string,
110
- turnCount: number,
111
- notifyFailureText?: string,
112
- headerTitle = "正在启动 Agent · 0秒",
113
- ): Promise<string | null> {
111
+ turnCount: number,
112
+ notifyFailureText?: string,
113
+ headerTitle = "正在启动 Agent · 0秒",
114
+ ): Promise<string | null> {
114
115
  for (let attempt = 1; attempt <= 2; attempt++) {
115
116
  let cardId: string | null = null;
116
117
  try {
117
- cardId = await platform.cardCreate(
118
- buildProgressCard("等待 Agent 输出...", { showStop: true, headerTitle }),
118
+ cardId = await platform.cardCreate(
119
+ buildProgressCard(progressView({ text: "等待 Agent 输出...", showStop: true, headerTitle })),
119
120
  );
120
121
  if (!cardId) throw new Error("empty card id");
121
122
  await platform.cardSend(chatId, cardId);
@@ -167,20 +168,20 @@ function platformForChat(chatId: string): PlatformAdapter | null {
167
168
  return chatPlatformMap.get(chatId) ?? platformRef;
168
169
  }
169
170
 
170
- const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
171
- const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
172
- const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
173
- const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
174
- export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
175
- export const RESPONSE_STALL_RECOVERY_NOTICE =
176
- `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
177
- export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
178
- "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。";
179
- const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
180
- let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
181
- let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
182
- let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
183
- let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
171
+ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
172
+ const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
173
+ const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
174
+ const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
175
+ export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
176
+ export const RESPONSE_STALL_RECOVERY_NOTICE =
177
+ `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
178
+ export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
179
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。";
180
+ const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
181
+ let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
182
+ let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
183
+ let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
184
+ let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
184
185
  let isProcessAliveImpl = (pid: number): boolean => {
185
186
  try {
186
187
  process.kill(pid, 0);
@@ -209,137 +210,137 @@ export function _setProcessMonitorIntervalForTest(ms: number): void {
209
210
  processMonitorIntervalMs = ms;
210
211
  }
211
212
 
212
- export function _resetProcessMonitorIntervalForTest(): void {
213
- processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
214
- }
215
-
216
- export function _setResponseStallTimeoutForTest(ms: number): void {
217
- responseStallTimeoutMs = ms;
218
- }
219
-
220
- export function _resetResponseStallTimeoutForTest(): void {
221
- responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
222
- }
223
-
224
- export function _setResponseStallCheckIntervalForTest(ms: number): void {
225
- responseStallCheckIntervalMs = ms;
226
- }
227
-
228
- export function _resetResponseStallCheckIntervalForTest(): void {
229
- responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
230
- }
231
-
232
- export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
233
- finalResponseCloseTimeoutMs = ms;
234
- }
235
-
236
- export function _resetFinalResponseCloseTimeoutForTest(): void {
237
- finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
238
- }
239
-
240
- function clearPromptProcessMonitor(sessionId: string): void {
213
+ export function _resetProcessMonitorIntervalForTest(): void {
214
+ processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
215
+ }
216
+
217
+ export function _setResponseStallTimeoutForTest(ms: number): void {
218
+ responseStallTimeoutMs = ms;
219
+ }
220
+
221
+ export function _resetResponseStallTimeoutForTest(): void {
222
+ responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
223
+ }
224
+
225
+ export function _setResponseStallCheckIntervalForTest(ms: number): void {
226
+ responseStallCheckIntervalMs = ms;
227
+ }
228
+
229
+ export function _resetResponseStallCheckIntervalForTest(): void {
230
+ responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
231
+ }
232
+
233
+ export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
234
+ finalResponseCloseTimeoutMs = ms;
235
+ }
236
+
237
+ export function _resetFinalResponseCloseTimeoutForTest(): void {
238
+ finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
239
+ }
240
+
241
+ function clearPromptProcessMonitor(sessionId: string): void {
241
242
  const prompt = activePrompts.get(sessionId);
242
243
  if (!prompt?.processMonitor) return;
243
244
  clearInterval(prompt.processMonitor);
244
245
  prompt.processMonitor = undefined;
245
- }
246
-
247
- function clearPromptResponseStallMonitor(sessionId: string): void {
248
- const prompt = activePrompts.get(sessionId);
249
- if (!prompt?.responseStallMonitor) return;
250
- clearInterval(prompt.responseStallMonitor);
251
- prompt.responseStallMonitor = undefined;
252
- }
253
-
254
- function clearPromptFinalResponseCloseTimer(sessionId: string): void {
255
- const prompt = activePrompts.get(sessionId);
256
- if (!prompt?.finalResponseCloseTimer) return;
257
- clearTimeout(prompt.finalResponseCloseTimer);
258
- prompt.finalResponseCloseTimer = undefined;
259
- }
260
-
261
- /**
262
- * 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
263
- * 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
264
- * 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
265
- */
266
- function scheduleFinalResponseCloseGuard(
267
- sessionId: string,
268
- runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
269
- ): void {
270
- if (runningPrompt.finalResponseCloseTimer) return;
271
-
272
- const timeoutMs = finalResponseCloseTimeoutMs;
273
- const handle = setTimeout(() => {
274
- const current = activePrompts.get(sessionId);
275
- if (
276
- !current
277
- || current !== runningPrompt
278
- || !current.finalResponseObserved
279
- || current.stopped
280
- || current.abnormalExit
281
- || current.resourceStuck
282
- || current.autoEnded
283
- ) {
284
- return;
285
- }
286
-
287
- current.finalResponseCloseTimer = undefined;
288
- clearPromptProcessMonitor(sessionId);
289
- clearPromptResponseStallMonitor(sessionId);
290
- try {
291
- current.closeSession?.();
292
- } catch (err) {
293
- console.warn(
294
- `[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
295
- );
296
- }
297
- current.controller.abort();
298
- void killProcessTree(current.processPid);
299
- console.warn(
300
- `[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
301
- );
302
- }, timeoutMs);
303
- handle.unref?.();
304
- runningPrompt.finalResponseCloseTimer = handle;
305
- }
306
-
307
- function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
308
- title: string;
309
- template?: string;
310
- } {
311
- if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
312
- if (status === "stopped") return { title: "已停止", template: "red" };
313
- if (status === "error") return { title: "异常结束", template: "red" };
314
- return { title: "完成" };
315
- }
316
-
317
- function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "auto_ended"): "done" | "stopped" {
318
- return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
319
- }
320
-
321
- function formatAutoEndedReply(finalReply: string): string {
322
- const reason = "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。";
323
- return finalReply
324
- ? `${reason}以下回复可能不完整。\n\n${finalReply}`
325
- : `${reason}本轮没有可发送的回复内容。`;
326
- }
327
-
328
- /**
329
- * 只监控用户无法判断是否仍有进展的两个阶段。思考、工具调用和搜索可能合法地
330
- * 长时间不产生回复字符,由资源监控负责识别真正僵死,不能在这里误杀。
331
- */
332
- function monitorsOutputProgress(kind: AgentActivityKind): boolean {
333
- return kind === "starting" || kind === "responding";
334
- }
335
-
336
- function formatTerminalReply(
337
- status: "running" | "done" | "stopped" | "error" | "auto_ended",
338
- finalReply: string,
339
- ): string | null {
340
- if (status === "auto_ended") return formatAutoEndedReply(finalReply);
341
- return finalReply || null;
342
- }
246
+ }
247
+
248
+ function clearPromptResponseStallMonitor(sessionId: string): void {
249
+ const prompt = activePrompts.get(sessionId);
250
+ if (!prompt?.responseStallMonitor) return;
251
+ clearInterval(prompt.responseStallMonitor);
252
+ prompt.responseStallMonitor = undefined;
253
+ }
254
+
255
+ function clearPromptFinalResponseCloseTimer(sessionId: string): void {
256
+ const prompt = activePrompts.get(sessionId);
257
+ if (!prompt?.finalResponseCloseTimer) return;
258
+ clearTimeout(prompt.finalResponseCloseTimer);
259
+ prompt.finalResponseCloseTimer = undefined;
260
+ }
261
+
262
+ /**
263
+ * 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
264
+ * 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
265
+ * 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
266
+ */
267
+ function scheduleFinalResponseCloseGuard(
268
+ sessionId: string,
269
+ runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
270
+ ): void {
271
+ if (runningPrompt.finalResponseCloseTimer) return;
272
+
273
+ const timeoutMs = finalResponseCloseTimeoutMs;
274
+ const handle = setTimeout(() => {
275
+ const current = activePrompts.get(sessionId);
276
+ if (
277
+ !current
278
+ || current !== runningPrompt
279
+ || !current.finalResponseObserved
280
+ || current.stopped
281
+ || current.abnormalExit
282
+ || current.resourceStuck
283
+ || current.autoEnded
284
+ ) {
285
+ return;
286
+ }
287
+
288
+ current.finalResponseCloseTimer = undefined;
289
+ clearPromptProcessMonitor(sessionId);
290
+ clearPromptResponseStallMonitor(sessionId);
291
+ try {
292
+ current.closeSession?.();
293
+ } catch (err) {
294
+ console.warn(
295
+ `[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
296
+ );
297
+ }
298
+ current.controller.abort();
299
+ void killProcessTree(current.processPid);
300
+ console.warn(
301
+ `[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
302
+ );
303
+ }, timeoutMs);
304
+ handle.unref?.();
305
+ runningPrompt.finalResponseCloseTimer = handle;
306
+ }
307
+
308
+ function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
309
+ title: string;
310
+ template?: string;
311
+ } {
312
+ if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
313
+ if (status === "stopped") return { title: "已停止", template: "red" };
314
+ if (status === "error") return { title: "异常结束", template: "red" };
315
+ return { title: "完成" };
316
+ }
317
+
318
+ function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "auto_ended"): "done" | "stopped" {
319
+ return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
320
+ }
321
+
322
+ function formatAutoEndedReply(finalReply: string): string {
323
+ const reason = "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。";
324
+ return finalReply
325
+ ? `${reason}以下回复可能不完整。\n\n${finalReply}`
326
+ : `${reason}本轮没有可发送的回复内容。`;
327
+ }
328
+
329
+ /**
330
+ * 只监控用户无法判断是否仍有进展的两个阶段。思考、工具调用和搜索可能合法地
331
+ * 长时间不产生回复字符,由资源监控负责识别真正僵死,不能在这里误杀。
332
+ */
333
+ function monitorsOutputProgress(kind: AgentActivityKind): boolean {
334
+ return kind === "starting" || kind === "responding";
335
+ }
336
+
337
+ function formatTerminalReply(
338
+ status: "running" | "done" | "stopped" | "error" | "auto_ended",
339
+ finalReply: string,
340
+ ): string | null {
341
+ if (status === "auto_ended") return formatAutoEndedReply(finalReply);
342
+ return finalReply || null;
343
+ }
343
344
 
344
345
  function isCardKitSequenceConflict(err: unknown): boolean {
345
346
  return err instanceof Error && err.message.includes("300317");
@@ -357,7 +358,7 @@ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): vo
357
358
  clearPromptProcessMonitor(sessionId);
358
359
  return;
359
360
  }
360
- if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded) return;
361
+ if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded) return;
361
362
  if (isProcessAliveImpl(info.pid)) return;
362
363
 
363
364
  current.abnormalExit = true;
@@ -467,23 +468,23 @@ export function resetState(): void {
467
468
  }
468
469
  chatSessionMap.clear();
469
470
  sessionInfoMap.clear();
470
- clearFeishuMessageLedgerMemory();
471
+ clearFeishuMessageLedgerMemory();
471
472
  lastMsgTimestamps.clear();
472
473
  chatPlatformMap.clear();
473
- for (const prompt of activePrompts.values()) {
474
- if (prompt.processMonitor) clearInterval(prompt.processMonitor);
475
- if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
476
- if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
477
- }
478
- activePrompts.clear();
479
- displayCards.clear();
480
- sessionModelOverrides.clear();
481
- sessionEffortOverrides.clear();
482
- sessionFastModeOverrides.clear();
483
- adapterCache.clear();
484
- stopUnifiedDisplayLoop();
485
- console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
486
- }
474
+ for (const prompt of activePrompts.values()) {
475
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
476
+ if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
477
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
478
+ }
479
+ activePrompts.clear();
480
+ displayCards.clear();
481
+ sessionModelOverrides.clear();
482
+ sessionEffortOverrides.clear();
483
+ sessionFastModeOverrides.clear();
484
+ adapterCache.clear();
485
+ stopUnifiedDisplayLoop();
486
+ console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
487
+ }
487
488
 
488
489
  // 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
489
490
  // 是 onReady/onReconnected 取代 resetState 的正确入口。
@@ -495,9 +496,9 @@ export function resetState(): void {
495
496
  const adapterCache = new Map<string, ToolAdapter>();
496
497
 
497
498
  // Per-session 模型覆盖(/model 命令设置,不持久化)
498
- const sessionModelOverrides = new Map<string, string>();
499
- const sessionEffortOverrides = new Map<string, string>();
500
- const sessionFastModeOverrides = new Map<string, boolean>();
499
+ const sessionModelOverrides = new Map<string, string>();
500
+ const sessionEffortOverrides = new Map<string, string>();
501
+ const sessionFastModeOverrides = new Map<string, boolean>();
501
502
 
502
503
  /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
503
504
  function getModelForSession(sessionId?: string): string {
@@ -509,47 +510,47 @@ function getModelForSession(sessionId?: string): string {
509
510
  }
510
511
 
511
512
  /** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
512
- export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
513
- if (sessionId) {
514
- const override = sessionModelOverrides.get(sessionId);
515
- if (override) return override;
516
- }
517
- if (tool === "cursor") return config.cursor.model;
518
- if (tool === "codex") return config.codex.model;
519
- if (tool === "ccc") return config.ccc.model;
520
- return CLAUDE_MODEL;
521
- }
522
-
523
- export function getEffectiveEffortForTool(tool: string, sessionId?: string): string {
524
- if (sessionId) {
525
- const override = sessionEffortOverrides.get(sessionId);
526
- if (override) return override;
527
- }
528
- if (tool === "claude" || tool === "codex") {
529
- return getDefaultEffortForTool(tool);
530
- }
531
- return "";
532
- }
533
-
534
- export function getEffectiveFastModeForTool(tool: string, sessionId?: string): boolean {
535
- if (tool !== "codex") return false;
536
- if (sessionId && sessionFastModeOverrides.has(sessionId)) {
537
- return sessionFastModeOverrides.get(sessionId) === true;
538
- }
539
- return config.codex.fastMode;
540
- }
541
-
542
- function setSessionChatAvatar(
543
- platform: PlatformAdapter,
544
- chatId: string,
545
- tool: string,
546
- status: string,
547
- sessionId: string,
548
- ): Promise<void> {
549
- return getEffectiveFastModeForTool(tool, sessionId)
550
- ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
551
- : platform.setChatAvatar(chatId, tool, status);
552
- }
513
+ export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
514
+ if (sessionId) {
515
+ const override = sessionModelOverrides.get(sessionId);
516
+ if (override) return override;
517
+ }
518
+ if (tool === "cursor") return config.cursor.model;
519
+ if (tool === "codex") return config.codex.model;
520
+ if (tool === "ccc") return config.ccc.model;
521
+ return CLAUDE_MODEL;
522
+ }
523
+
524
+ export function getEffectiveEffortForTool(tool: string, sessionId?: string): string {
525
+ if (sessionId) {
526
+ const override = sessionEffortOverrides.get(sessionId);
527
+ if (override) return override;
528
+ }
529
+ if (tool === "claude" || tool === "codex" || tool === "ccc") {
530
+ return getDefaultEffortForTool(tool);
531
+ }
532
+ return "";
533
+ }
534
+
535
+ export function getEffectiveFastModeForTool(tool: string, sessionId?: string): boolean {
536
+ if (tool !== "codex") return false;
537
+ if (sessionId && sessionFastModeOverrides.has(sessionId)) {
538
+ return sessionFastModeOverrides.get(sessionId) === true;
539
+ }
540
+ return config.codex.fastMode;
541
+ }
542
+
543
+ function setSessionChatAvatar(
544
+ platform: PlatformAdapter,
545
+ chatId: string,
546
+ tool: string,
547
+ status: string,
548
+ sessionId: string,
549
+ ): Promise<void> {
550
+ return getEffectiveFastModeForTool(tool, sessionId)
551
+ ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
552
+ : platform.setChatAvatar(chatId, tool, status);
553
+ }
553
554
 
554
555
  /** 为指定 session 设置模型覆盖(/model <name>) */
555
556
  export function setSessionModelOverride(sessionId: string, model: string): void {
@@ -558,53 +559,56 @@ export function setSessionModelOverride(sessionId: string, model: string): void
558
559
  }
559
560
 
560
561
  /** 清除指定 session 的模型覆盖(/model clear) */
561
- export function clearSessionModelOverride(sessionId: string): void {
562
- sessionModelOverrides.delete(sessionId);
563
- adapterCache.clear();
564
- }
565
-
566
- export function setSessionEffortOverride(sessionId: string, effort: string): void {
567
- sessionEffortOverrides.set(sessionId, effort);
568
- adapterCache.clear();
569
- }
570
-
571
- export function clearSessionEffortOverride(sessionId: string): void {
572
- sessionEffortOverrides.delete(sessionId);
573
- adapterCache.clear();
574
- }
575
-
576
- export function setSessionFastModeOverride(sessionId: string, fastMode: boolean): void {
577
- sessionFastModeOverrides.set(sessionId, fastMode);
578
- adapterCache.clear();
579
- }
580
-
581
- export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
582
- const effectiveModel = getEffectiveModelForTool(tool, sessionId);
583
- const effectiveEffort = getEffectiveEffortForTool(tool, sessionId);
584
- const effectiveFastMode = getEffectiveFastModeForTool(tool, sessionId);
585
- const cacheKey = `${tool}:${effectiveModel || ""}:${effectiveEffort || ""}:${effectiveFastMode ? "fast" : "default"}`;
586
- const cached = adapterCache.get(cacheKey);
587
- if (cached) return cached;
588
-
589
- let adapter: ToolAdapter;
590
- if (tool === "cursor") {
591
- adapter = createCursorAdapter({ model: effectiveModel || undefined });
592
- } else if (tool === "codex") {
593
- adapter = createCodexAdapter({
594
- model: effectiveModel || undefined,
595
- effort: effectiveEffort || undefined,
596
- fastMode: effectiveFastMode,
597
- });
598
- } else if (tool === "ccc") {
599
- adapter = createCccAdapter({ model: effectiveModel || undefined });
600
- } else {
601
- adapter = createClaudeAdapter({
602
- model: effectiveModel,
603
- subagentModel: CLAUDE_SUBAGENT_MODEL,
604
- effort: effectiveEffort,
605
- apiKey: CLAUDE_API_KEY,
606
- baseUrl: CLAUDE_BASE_URL,
607
- isEmpty: isAnthropicConfigEmpty,
562
+ export function clearSessionModelOverride(sessionId: string): void {
563
+ sessionModelOverrides.delete(sessionId);
564
+ adapterCache.clear();
565
+ }
566
+
567
+ export function setSessionEffortOverride(sessionId: string, effort: string): void {
568
+ sessionEffortOverrides.set(sessionId, effort);
569
+ adapterCache.clear();
570
+ }
571
+
572
+ export function clearSessionEffortOverride(sessionId: string): void {
573
+ sessionEffortOverrides.delete(sessionId);
574
+ adapterCache.clear();
575
+ }
576
+
577
+ export function setSessionFastModeOverride(sessionId: string, fastMode: boolean): void {
578
+ sessionFastModeOverrides.set(sessionId, fastMode);
579
+ adapterCache.clear();
580
+ }
581
+
582
+ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
583
+ const effectiveModel = getEffectiveModelForTool(tool, sessionId);
584
+ const effectiveEffort = getEffectiveEffortForTool(tool, sessionId);
585
+ const effectiveFastMode = getEffectiveFastModeForTool(tool, sessionId);
586
+ const cacheKey = `${tool}:${effectiveModel || ""}:${effectiveEffort || ""}:${effectiveFastMode ? "fast" : "default"}`;
587
+ const cached = adapterCache.get(cacheKey);
588
+ if (cached) return cached;
589
+
590
+ let adapter: ToolAdapter;
591
+ if (tool === "cursor") {
592
+ adapter = createCursorAdapter({ model: effectiveModel || undefined });
593
+ } else if (tool === "codex") {
594
+ adapter = createCodexAdapter({
595
+ model: effectiveModel || undefined,
596
+ effort: effectiveEffort || undefined,
597
+ fastMode: effectiveFastMode,
598
+ });
599
+ } else if (tool === "ccc") {
600
+ adapter = createCccAdapter({
601
+ model: effectiveModel || undefined,
602
+ effort: effectiveEffort || undefined,
603
+ });
604
+ } else {
605
+ adapter = createClaudeAdapter({
606
+ model: effectiveModel,
607
+ subagentModel: CLAUDE_SUBAGENT_MODEL,
608
+ effort: effectiveEffort,
609
+ apiKey: CLAUDE_API_KEY,
610
+ baseUrl: CLAUDE_BASE_URL,
611
+ isEmpty: isAnthropicConfigEmpty,
608
612
  maxTurn: CLAUDE_MAX_TURN,
609
613
  });
610
614
  }
@@ -676,13 +680,13 @@ export function _resetSessionToolsFileForTest(): void {
676
680
  export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
677
681
  let sessionRegistryFile = SESSION_REGISTRY_FILE;
678
682
 
679
- export interface SessionRegistryUpdate {
680
- chatId: string;
681
- sessionId: string;
682
- tool: string;
683
- /** 会话容器类型;旧 registry 没有该字段,读取时必须兼容。 */
684
- chatType?: string;
685
- chatName?: string;
683
+ export interface SessionRegistryUpdate {
684
+ chatId: string;
685
+ sessionId: string;
686
+ tool: string;
687
+ /** 会话容器类型;旧 registry 没有该字段,读取时必须兼容。 */
688
+ chatType?: string;
689
+ chatName?: string;
686
690
  turnCount?: number;
687
691
  lastContextTokens?: number;
688
692
  startTime?: number;
@@ -690,12 +694,12 @@ export interface SessionRegistryUpdate {
690
694
  running?: boolean;
691
695
  }
692
696
 
693
- interface SessionRegistryRecord {
694
- chatId: string;
695
- sessionId: string;
696
- tool: string;
697
- chatType?: string;
698
- chatName: string;
697
+ interface SessionRegistryRecord {
698
+ chatId: string;
699
+ sessionId: string;
700
+ tool: string;
701
+ chatType?: string;
702
+ chatName: string;
699
703
  turnCount: number;
700
704
  lastContextTokens: number;
701
705
  startTime: number;
@@ -756,11 +760,11 @@ export async function recordSessionRegistry(update: SessionRegistryUpdate): Prom
756
760
  const now = update.updatedAt ?? Date.now();
757
761
 
758
762
  data[update.chatId] = {
759
- chatId: update.chatId,
760
- sessionId: update.sessionId,
761
- tool: update.tool,
762
- chatType: update.chatType ?? existing?.chatType,
763
- chatName: update.chatName ?? existing?.chatName ?? "",
763
+ chatId: update.chatId,
764
+ sessionId: update.sessionId,
765
+ tool: update.tool,
766
+ chatType: update.chatType ?? existing?.chatType,
767
+ chatName: update.chatName ?? existing?.chatName ?? "",
764
768
  turnCount: update.turnCount ?? existing?.turnCount ?? 0,
765
769
  lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
766
770
  startTime: update.startTime ?? existing?.startTime ?? now,
@@ -1009,12 +1013,12 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
1009
1013
 
1010
1014
  // Step 3: 持久化(registry + sessions.json)。
1011
1015
  // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
1012
- await recordSessionRegistry({
1013
- chatId,
1014
- sessionId: newSessionId,
1015
- tool,
1016
- chatType,
1017
- chatName,
1016
+ await recordSessionRegistry({
1017
+ chatId,
1018
+ sessionId: newSessionId,
1019
+ tool,
1020
+ chatType,
1021
+ chatName,
1018
1022
  turnCount: initialTurnCount,
1019
1023
  lastContextTokens: initialContextTokens,
1020
1024
  startTime: now,
@@ -1037,58 +1041,58 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
1037
1041
  function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
1038
1042
  if (tool === "cursor") {
1039
1043
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
1040
- }
1041
- if (tool === "codex") {
1042
- const m = getEffectiveModelForTool(tool, sessionId);
1043
- const e = getEffectiveEffortForTool(tool, sessionId);
1044
- const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
1045
- const effortStr = e.trim() !== ""
1046
- ? `effort=${e}`
1047
- : "effort=(由 codex config.toml 决定)";
1048
- return `model=${modelStr}, ${effortStr}, fast=${getEffectiveFastModeForTool(tool, sessionId) ? "on" : "off"}`;
1049
- }
1050
- if (tool === "ccc") {
1051
- const m = getEffectiveModelForTool(tool, sessionId);
1052
- const modelStr = m.trim() !== "" ? m : "(not configured)";
1053
- return `model=${modelStr}, baseURL=${config.ccc.DEEPSEEK_BASE_URL}`;
1054
- }
1055
- return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
1056
- }
1057
-
1058
- export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
1059
- const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
1060
- const adapter = getAdapterForTool(tool);
1044
+ }
1045
+ if (tool === "codex") {
1046
+ const m = getEffectiveModelForTool(tool, sessionId);
1047
+ const e = getEffectiveEffortForTool(tool, sessionId);
1048
+ const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
1049
+ const effortStr = e.trim() !== ""
1050
+ ? `effort=${e}`
1051
+ : "effort=(由 codex config.toml 决定)";
1052
+ return `model=${modelStr}, ${effortStr}, fast=${getEffectiveFastModeForTool(tool, sessionId) ? "on" : "off"}`;
1053
+ }
1054
+ if (tool === "ccc") {
1055
+ const m = getEffectiveModelForTool(tool, sessionId);
1056
+ const modelStr = m.trim() !== "" ? m : "(not configured)";
1057
+ return `model=${modelStr}, baseURL=${config.ccc.DEEPSEEK_BASE_URL}`;
1058
+ }
1059
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
1060
+ }
1061
+
1062
+ export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
1063
+ const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
1064
+ const adapter = getAdapterForTool(tool);
1061
1065
  console.log(
1062
- `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
1063
- );
1064
-
1065
- // Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
1066
- // 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
1067
- // 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
1068
- const createController = new AbortController();
1069
- let createTimeout: ReturnType<typeof setTimeout> | undefined;
1070
- const timeoutError = new Error(
1071
- `${adapter.displayName} session creation timed out after 3 minutes without an init event`,
1072
- );
1073
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1074
- createTimeout = setTimeout(() => {
1075
- // 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
1076
- reject(timeoutError);
1077
- createController.abort();
1078
- }, responseStallTimeoutMs);
1079
- createTimeout.unref?.();
1080
- });
1081
-
1082
- let result: Awaited<ReturnType<ToolAdapter["createSession"]>>;
1083
- try {
1084
- result = await Promise.race([
1085
- adapter.createSession(cwd, createController.signal),
1086
- timeoutPromise,
1087
- ]);
1088
- } finally {
1089
- if (createTimeout) clearTimeout(createTimeout);
1090
- }
1091
- const sessionId = result.sessionId;
1066
+ `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
1067
+ );
1068
+
1069
+ // Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
1070
+ // 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
1071
+ // 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
1072
+ const createController = new AbortController();
1073
+ let createTimeout: ReturnType<typeof setTimeout> | undefined;
1074
+ const timeoutError = new Error(
1075
+ `${adapter.displayName} session creation timed out after 3 minutes without an init event`,
1076
+ );
1077
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1078
+ createTimeout = setTimeout(() => {
1079
+ // 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
1080
+ reject(timeoutError);
1081
+ createController.abort();
1082
+ }, responseStallTimeoutMs);
1083
+ createTimeout.unref?.();
1084
+ });
1085
+
1086
+ let result: Awaited<ReturnType<ToolAdapter["createSession"]>>;
1087
+ try {
1088
+ result = await Promise.race([
1089
+ adapter.createSession(cwd, createController.signal),
1090
+ timeoutPromise,
1091
+ ]);
1092
+ } finally {
1093
+ if (createTimeout) clearTimeout(createTimeout);
1094
+ }
1095
+ const sessionId = result.sessionId;
1092
1096
  console.log(`[${ts()}] → sessionId: ${sessionId}`);
1093
1097
 
1094
1098
  await saveSessionTool(sessionId, tool);
@@ -1110,41 +1114,41 @@ export async function resumeAndPrompt(
1110
1114
  return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
1111
1115
  }
1112
1116
 
1113
- // ---------------------------------------------------------------------------
1114
- // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
1115
- // ---------------------------------------------------------------------------
1116
-
1117
- interface RunAgentSessionOptions {
1118
- /**
1119
- * 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
1120
- * 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
1121
- */
1122
- autoRecovery?: boolean;
1123
- }
1124
-
1125
- export async function runAgentSession(
1126
- sessionId: string,
1127
- userText: string,
1128
- platform: PlatformAdapter,
1129
- _chatId: string,
1130
- msgTimestamp: number,
1131
- tool: string,
1132
- traceId?: string,
1133
- options: RunAgentSessionOptions = {},
1134
- ): Promise<void> {
1135
- const tid = traceId ?? "";
1136
-
1137
- // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
1138
- // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
1139
- // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
1140
- // 相同的卡片、状态和收尾行为。
1141
- const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
1142
- if (previousSessionId && previousSessionId !== sessionId) {
1143
- unbindChatFromSession(previousSessionId, _chatId);
1144
- }
1145
- bindChatToSession(sessionId, _chatId);
1146
-
1147
- // 记录用户最后发送消息的群(display loop 只推送到该群)
1117
+ // ---------------------------------------------------------------------------
1118
+ // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
1119
+ // ---------------------------------------------------------------------------
1120
+
1121
+ interface RunAgentSessionOptions {
1122
+ /**
1123
+ * 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
1124
+ * 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
1125
+ */
1126
+ autoRecovery?: boolean;
1127
+ }
1128
+
1129
+ export async function runAgentSession(
1130
+ sessionId: string,
1131
+ userText: string,
1132
+ platform: PlatformAdapter,
1133
+ _chatId: string,
1134
+ msgTimestamp: number,
1135
+ tool: string,
1136
+ traceId?: string,
1137
+ options: RunAgentSessionOptions = {},
1138
+ ): Promise<void> {
1139
+ const tid = traceId ?? "";
1140
+
1141
+ // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
1142
+ // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
1143
+ // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
1144
+ // 相同的卡片、状态和收尾行为。
1145
+ const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
1146
+ if (previousSessionId && previousSessionId !== sessionId) {
1147
+ unbindChatFromSession(previousSessionId, _chatId);
1148
+ }
1149
+ bindChatToSession(sessionId, _chatId);
1150
+
1151
+ // 记录用户最后发送消息的群(display loop 只推送到该群)
1148
1152
  // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
1149
1153
  recordChatPlatform(_chatId, platform);
1150
1154
  recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
@@ -1165,19 +1169,19 @@ export async function runAgentSession(
1165
1169
  // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
1166
1170
  const controller = new AbortController();
1167
1171
  const now = Date.now();
1168
- activePrompts.set(sessionId, {
1169
- controller,
1170
- stopped: false,
1171
- startTime: now,
1172
- autoRecovery: options.autoRecovery === true,
1173
- finalResponseObserved: false,
1174
- });
1172
+ activePrompts.set(sessionId, {
1173
+ controller,
1174
+ stopped: false,
1175
+ startTime: now,
1176
+ autoRecovery: options.autoRecovery === true,
1177
+ finalResponseObserved: false,
1178
+ });
1175
1179
 
1176
1180
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
1177
1181
  const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
1178
1182
  if (data.sessionId !== sessionId) return;
1179
1183
  const prompt = activePrompts.get(sessionId);
1180
- if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
1184
+ if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
1181
1185
  prompt.resourceStuck = true;
1182
1186
 
1183
1187
  const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
@@ -1214,12 +1218,12 @@ export async function runAgentSession(
1214
1218
  const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
1215
1219
  const skillVariables = {
1216
1220
  cwd,
1217
- session_id: sessionId,
1218
- im_skills_cache_dir: imSkillsCacheDir,
1219
- delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
1220
- send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
1221
- send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
1222
- send_image_script: join(feishuSkillDir, "send-image.mjs"),
1221
+ session_id: sessionId,
1222
+ im_skills_cache_dir: imSkillsCacheDir,
1223
+ delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
1224
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
1225
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
1226
+ send_image_script: join(feishuSkillDir, "send-image.mjs"),
1223
1227
  send_file_script: join(feishuSkillDir, "send-file.mjs"),
1224
1228
  download_video_script: join(feishuSkillDir, "download-video.mjs"),
1225
1229
  wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
@@ -1284,10 +1288,10 @@ export async function runAgentSession(
1284
1288
  // 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
1285
1289
  // 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
1286
1290
  // 再开始缓存问题对应的任务"。
1287
- const prevState = await readStreamState(sessionId);
1288
- if (prevState && prevState.status !== "running") {
1289
- const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
1290
- const displayChatId = pickDisplayChat(sessionId);
1291
+ const prevState = await readStreamState(sessionId);
1292
+ if (prevState && prevState.status !== "running") {
1293
+ const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
1294
+ const displayChatId = pickDisplayChat(sessionId);
1291
1295
  if (displayChatId) {
1292
1296
  const pp = platformForChat(displayChatId);
1293
1297
  const display = displayCards.get(displayChatId);
@@ -1302,12 +1306,12 @@ export async function runAgentSession(
1302
1306
  if (displayCards.get(displayChatId) !== display) {
1303
1307
  const finalStatus = turnFinalStatus(prevState.status);
1304
1308
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1305
- setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1309
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1306
1310
  } else {
1307
1311
  const nextSeq = display.sequence + 1;
1308
1312
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
1309
1313
  const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
1310
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1314
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1311
1315
  await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1312
1316
  console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${(err as Error).message}`);
1313
1317
  });
@@ -1319,49 +1323,49 @@ export async function runAgentSession(
1319
1323
  const finalStatus = turnFinalStatus(prevState.status);
1320
1324
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1321
1325
 
1322
- if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1323
- await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1324
- }
1325
- setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1326
+ if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1327
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1328
+ }
1329
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1326
1330
  }
1327
- } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1331
+ } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1328
1332
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
1329
1333
  const finalStatus = turnFinalStatus(prevState.status);
1330
1334
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1331
- await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1335
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1332
1336
  }
1333
1337
  // else: displayCards 无记录且无 finalReply → 无需处理
1334
1338
  }
1335
1339
  }
1336
1340
 
1337
1341
  // 初始化 stream-state.json
1338
- const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1339
- const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1340
- await writeStreamState(initialState);
1342
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1343
+ const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1344
+ await writeStreamState(initialState);
1341
1345
 
1342
1346
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
1343
1347
  // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
1344
1348
  const displayChatIdForNew = pickDisplayChat(sessionId);
1345
- if (displayChatIdForNew) {
1346
- const ppNew = platformForChat(displayChatIdForNew);
1347
- if (ppNew && ppNew.kind !== "wechat") {
1348
- const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1349
- const cardId = await createVisibleProgressCard(
1349
+ if (displayChatIdForNew) {
1350
+ const ppNew = platformForChat(displayChatIdForNew);
1351
+ if (ppNew && ppNew.kind !== "wechat") {
1352
+ const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1353
+ const cardId = await createVisibleProgressCard(
1350
1354
  ppNew,
1351
1355
  displayChatIdForNew,
1352
1356
  sessionId,
1353
- nextTurnCount,
1354
- "生成中卡片发送失败,结果将以文本形式发送。",
1355
- initialHeaderTitle,
1357
+ nextTurnCount,
1358
+ "生成中卡片发送失败,结果将以文本形式发送。",
1359
+ initialHeaderTitle,
1356
1360
  );
1357
1361
  if (cardId) {
1358
1362
  displayCards.set(displayChatIdForNew, {
1359
1363
  cardId,
1360
1364
  sequence: 1,
1361
1365
  cardBusy: false,
1362
- cardCreatedAt: Date.now(),
1363
- lastSentContent: "",
1364
- lastSentHeaderTitle: initialHeaderTitle,
1366
+ cardCreatedAt: Date.now(),
1367
+ lastSentContent: "",
1368
+ lastSentHeaderTitle: initialHeaderTitle,
1365
1369
  streamErrorNotified: false,
1366
1370
  sessionId,
1367
1371
  turnCount: nextTurnCount,
@@ -1387,7 +1391,7 @@ export async function runAgentSession(
1387
1391
  // 设置最后活跃群头像为 busy
1388
1392
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1389
1393
  if (activeCid) {
1390
- setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1394
+ setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1391
1395
  }
1392
1396
 
1393
1397
  const state: AccumulatorState = {
@@ -1397,106 +1401,106 @@ export async function runAgentSession(
1397
1401
  chunkCount: 0,
1398
1402
  };
1399
1403
 
1400
- let lastFileWrite = Date.now();
1401
- const FILE_WRITE_INTERVAL_MS = 2000;
1402
- const toolCallMap = new Map<string, { name: string; input: unknown }>();
1403
- let streamErrored = false;
1404
-
1405
- const runningPrompt = activePrompts.get(sessionId);
1406
- if (runningPrompt) {
1407
- // 必须在消费第一个事件前建立零字符基线。部分 CLI 卡死时只启动了进程,
1408
- // 甚至一个事件都不会 yield;若等循环体更新进度,这种会话会永久停在
1409
- // “正在启动 Agent”,也永远触发不了三分钟保护。
1410
- runningPrompt.responseProgress = observeResponseProgress(
1411
- undefined,
1412
- true,
1413
- 0,
1414
- activityTracker.activity.startedAt,
1415
- );
1416
-
1417
- const checkResponseStall = async () => {
1418
- const current = activePrompts.get(sessionId);
1419
- if (!current || current !== runningPrompt) {
1420
- clearPromptResponseStallMonitor(sessionId);
1421
- return;
1422
- }
1423
- if (
1424
- current.stopped
1425
- || current.abnormalExit
1426
- || current.resourceStuck
1427
- || current.autoEnded
1428
- || current.finalResponseObserved
1429
- || !monitorsOutputProgress(activityTracker.activity.kind)
1430
- || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1431
- ) {
1432
- return;
1433
- }
1434
-
1435
- const autoEndedAt = Date.now();
1436
- // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1437
- // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1438
- // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1439
- // 若当前已经是恢复轮,则不再预约第三轮。
1440
- if (!current.autoRecovery) {
1441
- reserveAutoRecovery(sessionId);
1442
- }
1443
- current.autoEnded = true;
1444
- current.autoEndedAt = autoEndedAt;
1445
- clearPromptResponseStallMonitor(sessionId);
1446
- clearPromptProcessMonitor(sessionId);
1447
-
1448
- // First publish an atomic terminal state so the card cannot keep claiming the
1449
- // Agent is running while process cleanup is underway.
1450
- await writeStreamState({
1451
- sessionId,
1452
- status: "auto_ended",
1453
- accumulatedContent: state.accumulatedContent,
1454
- finalReply: pickFinalReply(state).trim(),
1455
- activity: activityTracker.activity,
1456
- chunkCount: state.chunkCount,
1457
- turnCount: nextTurnCount,
1458
- contextTokens: existingInfo?.lastContextTokens ?? 0,
1459
- updatedAt: autoEndedAt,
1460
- cwd,
1461
- tool,
1462
- autoEndedAt,
1463
- });
1464
-
1465
- // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1466
- // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1467
- if (current.finalResponseObserved) {
1468
- current.autoEnded = false;
1469
- current.autoEndedAt = undefined;
1470
- cancelAutoRecoveryReservation(sessionId);
1471
- console.log(
1472
- `[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
1473
- );
1474
- return;
1475
- }
1476
-
1477
- try {
1478
- current.closeSession?.();
1479
- } catch (err) {
1480
- console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
1481
- }
1482
- current.controller.abort();
1483
- await killProcessTree(current.processPid);
1484
- console.warn(
1485
- `[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without startup or reply progress`,
1486
- );
1487
- };
1488
-
1489
- const responseStallMonitor = setInterval(() => {
1490
- void checkResponseStall().catch((err) => {
1491
- console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
1492
- });
1493
- }, responseStallCheckIntervalMs);
1494
- responseStallMonitor.unref?.();
1495
- runningPrompt.responseStallMonitor = responseStallMonitor;
1496
- }
1497
-
1498
- try {
1499
- for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1404
+ let lastFileWrite = Date.now();
1405
+ const FILE_WRITE_INTERVAL_MS = 2000;
1406
+ const toolCallMap = new Map<string, { name: string; input: unknown }>();
1407
+ let streamErrored = false;
1408
+
1409
+ const runningPrompt = activePrompts.get(sessionId);
1410
+ if (runningPrompt) {
1411
+ // 必须在消费第一个事件前建立零字符基线。部分 CLI 卡死时只启动了进程,
1412
+ // 甚至一个事件都不会 yield;若等循环体更新进度,这种会话会永久停在
1413
+ // “正在启动 Agent”,也永远触发不了三分钟保护。
1414
+ runningPrompt.responseProgress = observeResponseProgress(
1415
+ undefined,
1416
+ true,
1417
+ 0,
1418
+ activityTracker.activity.startedAt,
1419
+ );
1420
+
1421
+ const checkResponseStall = async () => {
1422
+ const current = activePrompts.get(sessionId);
1423
+ if (!current || current !== runningPrompt) {
1424
+ clearPromptResponseStallMonitor(sessionId);
1425
+ return;
1426
+ }
1427
+ if (
1428
+ current.stopped
1429
+ || current.abnormalExit
1430
+ || current.resourceStuck
1431
+ || current.autoEnded
1432
+ || current.finalResponseObserved
1433
+ || !monitorsOutputProgress(activityTracker.activity.kind)
1434
+ || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1435
+ ) {
1436
+ return;
1437
+ }
1438
+
1439
+ const autoEndedAt = Date.now();
1440
+ // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1441
+ // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1442
+ // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1443
+ // 若当前已经是恢复轮,则不再预约第三轮。
1444
+ if (!current.autoRecovery) {
1445
+ reserveAutoRecovery(sessionId);
1446
+ }
1447
+ current.autoEnded = true;
1448
+ current.autoEndedAt = autoEndedAt;
1449
+ clearPromptResponseStallMonitor(sessionId);
1450
+ clearPromptProcessMonitor(sessionId);
1451
+
1452
+ // First publish an atomic terminal state so the card cannot keep claiming the
1453
+ // Agent is running while process cleanup is underway.
1454
+ await writeStreamState({
1455
+ sessionId,
1456
+ status: "auto_ended",
1457
+ accumulatedContent: state.accumulatedContent,
1458
+ finalReply: pickFinalReply(state).trim(),
1459
+ activity: activityTracker.activity,
1460
+ chunkCount: state.chunkCount,
1461
+ turnCount: nextTurnCount,
1462
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1463
+ updatedAt: autoEndedAt,
1464
+ cwd,
1465
+ tool,
1466
+ autoEndedAt,
1467
+ });
1468
+
1469
+ // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1470
+ // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1471
+ if (current.finalResponseObserved) {
1472
+ current.autoEnded = false;
1473
+ current.autoEndedAt = undefined;
1474
+ cancelAutoRecoveryReservation(sessionId);
1475
+ console.log(
1476
+ `[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
1477
+ );
1478
+ return;
1479
+ }
1480
+
1481
+ try {
1482
+ current.closeSession?.();
1483
+ } catch (err) {
1484
+ console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
1485
+ }
1486
+ current.controller.abort();
1487
+ await killProcessTree(current.processPid);
1488
+ console.warn(
1489
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without startup or reply progress`,
1490
+ );
1491
+ };
1492
+
1493
+ const responseStallMonitor = setInterval(() => {
1494
+ void checkResponseStall().catch((err) => {
1495
+ console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
1496
+ });
1497
+ }, responseStallCheckIntervalMs);
1498
+ responseStallMonitor.unref?.();
1499
+ runningPrompt.responseStallMonitor = responseStallMonitor;
1500
+ }
1501
+
1502
+ try {
1503
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1500
1504
  onProcessStart: (processInfo) => {
1501
1505
  startPromptProcessMonitor(sessionId, processInfo);
1502
1506
  if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
@@ -1505,27 +1509,27 @@ export async function runAgentSession(
1505
1509
  clearPromptProcessMonitor(sessionId);
1506
1510
  if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1507
1511
  },
1508
- onSessionCreated: (closeSession) => {
1509
- const prompt = activePrompts.get(sessionId);
1510
- if (prompt) prompt.closeSession = closeSession;
1511
- },
1512
- })) {
1513
- if (unifiedMsg.isFinalResponse) {
1514
- const prompt = activePrompts.get(sessionId);
1515
- if (prompt && prompt === runningPrompt) {
1516
- // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1517
- // 最终事件后仍把本轮判为停滞。
1518
- if (!prompt.finalResponseObserved) {
1519
- prompt.finalResponseObserved = true;
1520
- scheduleFinalResponseCloseGuard(sessionId, prompt);
1521
- }
1522
- }
1523
- }
1524
-
1525
- let activityChanged = false;
1526
- for (const block of unifiedMsg.blocks) {
1527
- if (updateAgentActivity(activityTracker, block)) activityChanged = true;
1528
- accumulateBlockContent(block, state, toolCallMap);
1512
+ onSessionCreated: (closeSession) => {
1513
+ const prompt = activePrompts.get(sessionId);
1514
+ if (prompt) prompt.closeSession = closeSession;
1515
+ },
1516
+ })) {
1517
+ if (unifiedMsg.isFinalResponse) {
1518
+ const prompt = activePrompts.get(sessionId);
1519
+ if (prompt && prompt === runningPrompt) {
1520
+ // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1521
+ // 最终事件后仍把本轮判为停滞。
1522
+ if (!prompt.finalResponseObserved) {
1523
+ prompt.finalResponseObserved = true;
1524
+ scheduleFinalResponseCloseGuard(sessionId, prompt);
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ let activityChanged = false;
1530
+ for (const block of unifiedMsg.blocks) {
1531
+ if (updateAgentActivity(activityTracker, block)) activityChanged = true;
1532
+ accumulateBlockContent(block, state, toolCallMap);
1529
1533
 
1530
1534
  if (block.type === "compact_boundary" && block.post_tokens) {
1531
1535
  for (const cid of getChatsForSession(sessionId)) {
@@ -1539,32 +1543,32 @@ export async function runAgentSession(
1539
1543
  lastContextTokens: block.post_tokens,
1540
1544
  running: true,
1541
1545
  });
1542
- }
1543
- }
1544
-
1545
- const prompt = activePrompts.get(sessionId);
1546
- if (prompt && !prompt.autoEnded) {
1547
- const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1548
- prompt.responseProgress = observeResponseProgress(
1549
- // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1550
- // 重新计时;其它活动阶段会清空观察窗口。
1551
- activityChanged ? undefined : prompt.responseProgress,
1552
- monitorsOutputProgress(activityTracker.activity.kind),
1553
- totalChars,
1554
- Date.now(),
1555
- );
1556
- }
1557
-
1558
- // 定时写入文件
1546
+ }
1547
+ }
1548
+
1549
+ const prompt = activePrompts.get(sessionId);
1550
+ if (prompt && !prompt.autoEnded) {
1551
+ const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1552
+ prompt.responseProgress = observeResponseProgress(
1553
+ // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1554
+ // 重新计时;其它活动阶段会清空观察窗口。
1555
+ activityChanged ? undefined : prompt.responseProgress,
1556
+ monitorsOutputProgress(activityTracker.activity.kind),
1557
+ totalChars,
1558
+ Date.now(),
1559
+ );
1560
+ }
1561
+
1562
+ // 定时写入文件
1559
1563
  const now2 = Date.now();
1560
- if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1561
- lastFileWrite = now2;
1562
- await writeStreamState({
1564
+ if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1565
+ lastFileWrite = now2;
1566
+ await writeStreamState({
1563
1567
  sessionId,
1564
1568
  status: "running",
1565
- accumulatedContent: state.accumulatedContent,
1566
- finalReply: pickFinalReply(state),
1567
- activity: activityTracker.activity,
1569
+ accumulatedContent: state.accumulatedContent,
1570
+ finalReply: pickFinalReply(state),
1571
+ activity: activityTracker.activity,
1568
1572
  chunkCount: state.chunkCount,
1569
1573
  turnCount: nextTurnCount,
1570
1574
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1573,58 +1577,58 @@ export async function runAgentSession(
1573
1577
  tool,
1574
1578
  });
1575
1579
  }
1576
- }
1577
- } catch (streamErr) {
1578
- streamErrored = true;
1579
- console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1580
- } finally {
1580
+ }
1581
+ } catch (streamErr) {
1582
+ streamErrored = true;
1583
+ console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1584
+ } finally {
1581
1585
  // 标记 prompt 结束
1582
1586
  resourceMonitor.off("stuck", onResourceStuck);
1583
1587
  const prompt = activePrompts.get(sessionId);
1584
- const wasStopped = prompt?.stopped ?? false;
1585
- const wasAbnormalExit = prompt?.abnormalExit ?? false;
1586
- const wasResourceStuck = prompt?.resourceStuck ?? false;
1587
- const timeoutTriggered = prompt?.autoEnded ?? false;
1588
- const completedAtTimeoutBoundary =
1589
- timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1590
- const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1591
- const wasAutoRecovery = prompt?.autoRecovery ?? false;
1592
- const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1593
- clearPromptResponseStallMonitor(sessionId);
1594
- clearPromptProcessMonitor(sessionId);
1595
- clearPromptFinalResponseCloseTimer(sessionId);
1596
- markSessionFinalizing(sessionId);
1597
- activePrompts.delete(sessionId);
1598
-
1599
- try {
1600
- if (completedAtTimeoutBoundary) {
1601
- // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1602
- // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1603
- cancelAutoRecoveryReservation(sessionId);
1604
- console.log(
1605
- `[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
1606
- );
1607
- }
1608
- // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1609
- // 永久残留 running=true。
1610
- const finalizationChatIds = [...new Set([
1611
- ...getChatsForSession(sessionId),
1612
- _chatId,
1613
- ])];
1614
- // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1615
- // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1616
- // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1617
- // 运行中并更新旧卡片,而不是新建卡片。
1618
- const finalStatus = completedAtTimeoutBoundary
1619
- ? "done"
1620
- : wasAutoEnded
1621
- ? "auto_ended"
1622
- : (streamErrored || wasAbnormalExit || wasResourceStuck)
1623
- ? "error"
1624
- : wasStopped
1625
- ? "stopped"
1626
- : "done";
1627
- const finalReply = pickFinalReply(state).trim();
1588
+ const wasStopped = prompt?.stopped ?? false;
1589
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1590
+ const wasResourceStuck = prompt?.resourceStuck ?? false;
1591
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1592
+ const completedAtTimeoutBoundary =
1593
+ timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1594
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1595
+ const wasAutoRecovery = prompt?.autoRecovery ?? false;
1596
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1597
+ clearPromptResponseStallMonitor(sessionId);
1598
+ clearPromptProcessMonitor(sessionId);
1599
+ clearPromptFinalResponseCloseTimer(sessionId);
1600
+ markSessionFinalizing(sessionId);
1601
+ activePrompts.delete(sessionId);
1602
+
1603
+ try {
1604
+ if (completedAtTimeoutBoundary) {
1605
+ // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1606
+ // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1607
+ cancelAutoRecoveryReservation(sessionId);
1608
+ console.log(
1609
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
1610
+ );
1611
+ }
1612
+ // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1613
+ // 永久残留 running=true。
1614
+ const finalizationChatIds = [...new Set([
1615
+ ...getChatsForSession(sessionId),
1616
+ _chatId,
1617
+ ])];
1618
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1619
+ // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1620
+ // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1621
+ // 运行中并更新旧卡片,而不是新建卡片。
1622
+ const finalStatus = completedAtTimeoutBoundary
1623
+ ? "done"
1624
+ : wasAutoEnded
1625
+ ? "auto_ended"
1626
+ : (streamErrored || wasAbnormalExit || wasResourceStuck)
1627
+ ? "error"
1628
+ : wasStopped
1629
+ ? "stopped"
1630
+ : "done";
1631
+ const finalReply = pickFinalReply(state).trim();
1628
1632
 
1629
1633
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1630
1634
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1644,25 +1648,25 @@ export async function runAgentSession(
1644
1648
  await writeStreamState({
1645
1649
  sessionId,
1646
1650
  status: finalStatus,
1647
- accumulatedContent: state.accumulatedContent,
1648
- finalReply: finalReplyToWrite,
1649
- activity: activityTracker.activity,
1651
+ accumulatedContent: state.accumulatedContent,
1652
+ finalReply: finalReplyToWrite,
1653
+ activity: activityTracker.activity,
1650
1654
  chunkCount: state.chunkCount,
1651
1655
  turnCount: nextTurnCount,
1652
1656
  contextTokens: existingInfo?.lastContextTokens ?? 0,
1653
1657
  updatedAt: Date.now(),
1654
1658
  cwd,
1655
- tool,
1656
- ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1657
- ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1658
- });
1659
-
1660
- // display loop 下一轮会读到最终状态并发送消息
1661
-
1662
- let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1663
-
1664
- if (wasStopped) {
1665
- for (const cid of finalizationChatIds) {
1659
+ tool,
1660
+ ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1661
+ ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1662
+ });
1663
+
1664
+ // display loop 下一轮会读到最终状态并发送消息
1665
+
1666
+ let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1667
+
1668
+ if (wasStopped) {
1669
+ for (const cid of finalizationChatIds) {
1666
1670
  const finfo = sessionInfoMap.get(cid);
1667
1671
  await recordSessionRegistry({
1668
1672
  chatId: cid,
@@ -1674,62 +1678,15 @@ export async function runAgentSession(
1674
1678
  running: false,
1675
1679
  });
1676
1680
  }
1677
- const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1681
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1678
1682
  if (active1) {
1679
1683
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1680
- setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1684
+ setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1681
1685
  }
1682
1686
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1683
1687
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1684
- } else if (wasAutoEnded) {
1685
- for (const cid of finalizationChatIds) {
1686
- const finfo = sessionInfoMap.get(cid);
1687
- await recordSessionRegistry({
1688
- chatId: cid,
1689
- sessionId,
1690
- tool,
1691
- turnCount: finfo?.turnCount ?? nextTurnCount,
1692
- lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1693
- startTime: finfo?.startTime ?? now,
1694
- running: false,
1695
- });
1696
- }
1697
- const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1698
- if (activeAutoEnded) {
1699
- const pp = platformForChat(activeAutoEnded) ?? platform;
1700
- const terminalState = await readStreamState(sessionId);
1701
- if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1702
- await sendFinalReplyTextOnce(
1703
- pp,
1704
- activeAutoEnded,
1705
- sessionId,
1706
- nextTurnCount,
1707
- formatAutoEndedReply(finalReplyToWrite),
1708
- );
1709
- }
1710
- setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
1711
-
1712
- if (wasAutoRecovery) {
1713
- // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1714
- // 自动链,避免第三轮及之后的无限续跑。
1715
- await pp.sendText(
1716
- activeAutoEnded,
1717
- RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1718
- ).catch(() => {});
1719
- } else if (hasAutoRecoveryReservation(sessionId)) {
1720
- // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1721
- // 内部恢复仍由 reservation 保证先于普通缓存消息。
1722
- await pp.sendText(
1723
- activeAutoEnded,
1724
- RESPONSE_STALL_RECOVERY_NOTICE,
1725
- ).catch(() => {});
1726
- autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1727
- }
1728
- }
1729
- console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled startup or response output (content chunks: ${state.chunkCount})`);
1730
- if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1731
- } else if (wasAbnormalExit) {
1732
- for (const cid of finalizationChatIds) {
1688
+ } else if (wasAutoEnded) {
1689
+ for (const cid of finalizationChatIds) {
1733
1690
  const finfo = sessionInfoMap.get(cid);
1734
1691
  await recordSessionRegistry({
1735
1692
  chatId: cid,
@@ -1741,12 +1698,59 @@ export async function runAgentSession(
1741
1698
  running: false,
1742
1699
  });
1743
1700
  }
1744
- const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1745
- if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1701
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1702
+ if (activeAutoEnded) {
1703
+ const pp = platformForChat(activeAutoEnded) ?? platform;
1704
+ const terminalState = await readStreamState(sessionId);
1705
+ if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1706
+ await sendFinalReplyTextOnce(
1707
+ pp,
1708
+ activeAutoEnded,
1709
+ sessionId,
1710
+ nextTurnCount,
1711
+ formatAutoEndedReply(finalReplyToWrite),
1712
+ );
1713
+ }
1714
+ setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
1715
+
1716
+ if (wasAutoRecovery) {
1717
+ // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1718
+ // 自动链,避免第三轮及之后的无限续跑。
1719
+ await pp.sendText(
1720
+ activeAutoEnded,
1721
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1722
+ ).catch(() => {});
1723
+ } else if (hasAutoRecoveryReservation(sessionId)) {
1724
+ // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1725
+ // 内部恢复仍由 reservation 保证先于普通缓存消息。
1726
+ await pp.sendText(
1727
+ activeAutoEnded,
1728
+ RESPONSE_STALL_RECOVERY_NOTICE,
1729
+ ).catch(() => {});
1730
+ autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1731
+ }
1732
+ }
1733
+ console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled startup or response output (content chunks: ${state.chunkCount})`);
1734
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1735
+ } else if (wasAbnormalExit) {
1736
+ for (const cid of finalizationChatIds) {
1737
+ const finfo = sessionInfoMap.get(cid);
1738
+ await recordSessionRegistry({
1739
+ chatId: cid,
1740
+ sessionId,
1741
+ tool,
1742
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1743
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1744
+ startTime: finfo?.startTime ?? now,
1745
+ running: false,
1746
+ });
1747
+ }
1748
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1749
+ if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1746
1750
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1747
1751
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1748
- } else {
1749
- for (const cid of finalizationChatIds) {
1752
+ } else {
1753
+ for (const cid of finalizationChatIds) {
1750
1754
  const finfo = sessionInfoMap.get(cid);
1751
1755
  await recordSessionRegistry({
1752
1756
  chatId: cid,
@@ -1758,107 +1762,107 @@ export async function runAgentSession(
1758
1762
  running: false,
1759
1763
  });
1760
1764
  }
1761
- const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1765
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1762
1766
  if (active2) {
1763
1767
  const terminalState = await readStreamState(sessionId);
1764
1768
  if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1765
1769
  const pp = platformForChat(active2) ?? platform;
1766
1770
  await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1767
1771
  }
1768
- setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1772
+ setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1773
+ }
1774
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1775
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1776
+ }
1777
+
1778
+ // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1779
+ if (
1780
+ wasAutoEnded
1781
+ && hasAutoRecoveryReservation(sessionId)
1782
+ && !autoRecoveryTarget
1783
+ ) {
1784
+ cancelAutoRecoveryReservation(sessionId);
1785
+ }
1786
+ const shouldScheduleAutoRecovery =
1787
+ autoRecoveryTarget !== undefined
1788
+ && hasAutoRecoveryReservation(sessionId);
1789
+
1790
+ // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1791
+ // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1792
+ let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
1793
+ if (wasStopped) {
1794
+ const discarded = dequeueMessage(sessionId);
1795
+ if (discarded) {
1796
+ console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1797
+ }
1798
+ } else if (!shouldScheduleAutoRecovery) {
1799
+ // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1800
+ // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1801
+ queuedForConsumption = dequeueMessage(sessionId);
1802
+ }
1803
+
1804
+ if (queuedForConsumption) {
1805
+ const queued = queuedForConsumption;
1806
+ // 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
1807
+ // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1808
+ // 用保存的 chat 替代 queued.chatId 作为 display 目标)。
1809
+ const preservedChat = getLastActiveChat(sessionId);
1810
+ if (preservedChat && preservedChat !== queued.chatId) {
1811
+ setQueuePreservedChat(sessionId, preservedChat);
1769
1812
  }
1770
- console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1771
- if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1772
- }
1773
-
1774
- // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1775
- if (
1776
- wasAutoEnded
1777
- && hasAutoRecoveryReservation(sessionId)
1778
- && !autoRecoveryTarget
1779
- ) {
1780
- cancelAutoRecoveryReservation(sessionId);
1781
- }
1782
- const shouldScheduleAutoRecovery =
1783
- autoRecoveryTarget !== undefined
1784
- && hasAutoRecoveryReservation(sessionId);
1785
-
1786
- // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1787
- // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1788
- let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
1789
- if (wasStopped) {
1790
- const discarded = dequeueMessage(sessionId);
1791
- if (discarded) {
1792
- console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1793
- }
1794
- } else if (!shouldScheduleAutoRecovery) {
1795
- // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1796
- // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1797
- queuedForConsumption = dequeueMessage(sessionId);
1798
- }
1799
-
1800
- if (queuedForConsumption) {
1801
- const queued = queuedForConsumption;
1802
- // 队列消息可能来自其他群,保存当前 display chat 避免 display loop
1803
- // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1804
- // 用保存的 chat 替代 queued.chatId 作为 display 目标)。
1805
- const preservedChat = getLastActiveChat(sessionId);
1806
- if (preservedChat && preservedChat !== queued.chatId) {
1807
- setQueuePreservedChat(sessionId, preservedChat);
1808
- }
1809
- console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1810
- // setTimeout 而非 setImmediate:给 display loop 的 setInterval
1811
- // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1812
- setTimeout(() => {
1813
- consumeQueuedMessage(platform, queued);
1814
- }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1815
- }
1816
-
1817
- if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1818
- const target = autoRecoveryTarget;
1819
- console.log(
1820
- `[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
1821
- );
1822
- // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1823
- // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1824
- // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1825
- // 因而不存在普通用户消息可插入的事件循环空窗。
1826
- setTimeout(() => {
1827
- if (!hasAutoRecoveryReservation(sessionId)) return;
1828
- const recoveryRun = runAgentSession(
1829
- sessionId,
1830
- RESPONSE_STALL_RECOVERY_PROMPT,
1831
- target.platform,
1832
- target.chatId,
1833
- Date.now(),
1834
- tool,
1835
- undefined,
1836
- { autoRecovery: true },
1837
- );
1838
- consumeAutoRecoveryReservation(sessionId);
1839
- void recoveryRun.catch((err) => {
1840
- console.error(
1841
- `[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
1842
- );
1843
- target.platform.sendText(
1844
- target.chatId,
1845
- `⚠️ 自动续跑启动失败:${(err as Error).message}`,
1846
- ).catch(() => {});
1847
-
1848
- // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1849
- // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1850
- const queued = dequeueMessage(sessionId);
1851
- if (queued) {
1852
- consumeQueuedMessage(target.platform, queued);
1853
- }
1854
- });
1855
- }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1856
- }
1857
- } finally {
1858
- clearSessionFinalizing(sessionId);
1859
- }
1860
- }
1861
- }
1813
+ console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1814
+ // setTimeout 而非 setImmediate:给 display loop setInterval
1815
+ // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1816
+ setTimeout(() => {
1817
+ consumeQueuedMessage(platform, queued);
1818
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1819
+ }
1820
+
1821
+ if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1822
+ const target = autoRecoveryTarget;
1823
+ console.log(
1824
+ `[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
1825
+ );
1826
+ // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1827
+ // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1828
+ // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1829
+ // 因而不存在普通用户消息可插入的事件循环空窗。
1830
+ setTimeout(() => {
1831
+ if (!hasAutoRecoveryReservation(sessionId)) return;
1832
+ const recoveryRun = runAgentSession(
1833
+ sessionId,
1834
+ RESPONSE_STALL_RECOVERY_PROMPT,
1835
+ target.platform,
1836
+ target.chatId,
1837
+ Date.now(),
1838
+ tool,
1839
+ undefined,
1840
+ { autoRecovery: true },
1841
+ );
1842
+ consumeAutoRecoveryReservation(sessionId);
1843
+ void recoveryRun.catch((err) => {
1844
+ console.error(
1845
+ `[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
1846
+ );
1847
+ target.platform.sendText(
1848
+ target.chatId,
1849
+ `⚠️ 自动续跑启动失败:${(err as Error).message}`,
1850
+ ).catch(() => {});
1851
+
1852
+ // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1853
+ // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1854
+ const queued = dequeueMessage(sessionId);
1855
+ if (queued) {
1856
+ consumeQueuedMessage(target.platform, queued);
1857
+ }
1858
+ });
1859
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1860
+ }
1861
+ } finally {
1862
+ clearSessionFinalizing(sessionId);
1863
+ }
1864
+ }
1865
+ }
1862
1866
 
1863
1867
  // ---------------------------------------------------------------------------
1864
1868
  // startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
@@ -1932,14 +1936,14 @@ export function startUnifiedDisplayLoop(): void {
1932
1936
  // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1933
1937
  // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1934
1938
  // 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
1935
- if (activePrompts.has(sessionId)) continue;
1936
-
1937
- const tail = "━━━ 回答结束 ━━━";
1938
- const finalMsg = state.status === "auto_ended"
1939
- ? formatAutoEndedReply(remaining)
1940
- : remaining
1941
- ? remaining + "\n" + tail
1942
- : tail;
1939
+ if (activePrompts.has(sessionId)) continue;
1940
+
1941
+ const tail = "━━━ 回答结束 ━━━";
1942
+ const finalMsg = state.status === "auto_ended"
1943
+ ? formatAutoEndedReply(remaining)
1944
+ : remaining
1945
+ ? remaining + "\n" + tail
1946
+ : tail;
1943
1947
  if (!isFinalReplySentForTurn(state)) {
1944
1948
  await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1945
1949
  }
@@ -1963,7 +1967,7 @@ export function startUnifiedDisplayLoop(): void {
1963
1967
  const nextSeq = display.sequence + 1;
1964
1968
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1965
1969
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1966
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1970
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1967
1971
  await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1968
1972
  display.sequence = nextSeq;
1969
1973
  terminalCardUpdateAccepted = true;
@@ -1988,12 +1992,12 @@ export function startUnifiedDisplayLoop(): void {
1988
1992
  continue;
1989
1993
  }
1990
1994
 
1991
- let terminalTextDelivered = true;
1992
- const terminalReply = formatTerminalReply(state.status, state.finalReply);
1993
- if (terminalReply) {
1994
- if (!isFinalReplySentForTurn(state)) {
1995
- terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
1996
- }
1995
+ let terminalTextDelivered = true;
1996
+ const terminalReply = formatTerminalReply(state.status, state.finalReply);
1997
+ if (terminalReply) {
1998
+ if (!isFinalReplySentForTurn(state)) {
1999
+ terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
2000
+ }
1997
2001
  } else if (state.accumulatedContent.trim()) {
1998
2002
  const short = truncateContent(state.accumulatedContent, 30, 4000);
1999
2003
  terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
@@ -2008,7 +2012,7 @@ export function startUnifiedDisplayLoop(): void {
2008
2012
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
2009
2013
  displayCards.delete(chatId);
2010
2014
  }
2011
- setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
2015
+ setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
2012
2016
  console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
2013
2017
  } else {
2014
2018
  // running: 创建或更新展示
@@ -2045,16 +2049,16 @@ export function startUnifiedDisplayLoop(): void {
2045
2049
  }
2046
2050
  } else {
2047
2051
  // 非 WeChat: 卡片流程
2048
- if (display.turnCount !== state.turnCount) {
2052
+ if (display.turnCount !== state.turnCount) {
2049
2053
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
2050
2054
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
2051
2055
  displayCards.delete(chatId);
2052
- continue;
2053
- }
2054
-
2055
- const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
2056
-
2057
- // 卡片轮转
2056
+ continue;
2057
+ }
2058
+
2059
+ const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
2060
+
2061
+ // 卡片轮转
2058
2062
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
2059
2063
  display.cardBusy = true;
2060
2064
  try {
@@ -2062,9 +2066,9 @@ export function startUnifiedDisplayLoop(): void {
2062
2066
  p,
2063
2067
  chatId,
2064
2068
  sessionId,
2065
- display.turnCount,
2066
- display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
2067
- activityHeaderTitle,
2069
+ display.turnCount,
2070
+ display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
2071
+ activityHeaderTitle,
2068
2072
  );
2069
2073
  if (!newCardId) {
2070
2074
  display.streamErrorNotified = true;
@@ -2072,7 +2076,7 @@ export function startUnifiedDisplayLoop(): void {
2072
2076
  }
2073
2077
  const oldSeqBase = display.sequence;
2074
2078
  const oldContent = state.accumulatedContent + state.finalReply;
2075
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "上一阶段记录" });
2079
+ const oldCard = buildProgressCard(progressView({ text: truncateContent(oldContent) || " ", status: "done", showStop: false, headerTitle: "上一阶段记录" }));
2076
2080
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
2077
2081
  display.sequence = oldSeqBase + 1;
2078
2082
  }).catch(err => {
@@ -2083,10 +2087,10 @@ export function startUnifiedDisplayLoop(): void {
2083
2087
  display.sequence = 1;
2084
2088
  display.cardCreatedAt = Date.now();
2085
2089
  display.rotationAccLen = state.accumulatedContent.length;
2086
- display.rotationFinalReply = state.finalReply;
2087
- display.lastSentContent = "";
2088
- display.lastSentHeaderTitle = activityHeaderTitle;
2089
- display.streamErrorNotified = false;
2090
+ display.rotationFinalReply = state.finalReply;
2091
+ display.lastSentContent = "";
2092
+ display.lastSentHeaderTitle = activityHeaderTitle;
2093
+ display.streamErrorNotified = false;
2090
2094
  } catch (err) {
2091
2095
  console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
2092
2096
  } finally {
@@ -2104,24 +2108,21 @@ export function startUnifiedDisplayLoop(): void {
2104
2108
  replyDelta = state.finalReply.slice(rotReply.length);
2105
2109
  } else {
2106
2110
  replyDelta = state.finalReply;
2107
- }
2108
- const delta = (accDelta + replyDelta).trim();
2109
-
2110
- display.dotCount = (display.dotCount % 9) + 1;
2111
- let deltaBase = delta;
2112
- if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
2113
- const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
2114
- if (
2115
- displayContent === display.lastSentContent
2116
- && activityHeaderTitle === display.lastSentHeaderTitle
2117
- ) continue;
2118
-
2119
- display.lastSentContent = displayContent;
2120
- display.lastSentHeaderTitle = activityHeaderTitle;
2121
- const deltaCard = buildProgressCard(truncateContent(displayContent) || "等待 Agent 输出...", {
2122
- showStop: true,
2123
- headerTitle: activityHeaderTitle,
2124
- });
2111
+ }
2112
+ const delta = (accDelta + replyDelta).trim();
2113
+
2114
+ display.dotCount = (display.dotCount % 9) + 1;
2115
+ let deltaBase = delta;
2116
+ if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
2117
+ const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
2118
+ if (
2119
+ displayContent === display.lastSentContent
2120
+ && activityHeaderTitle === display.lastSentHeaderTitle
2121
+ ) continue;
2122
+
2123
+ display.lastSentContent = displayContent;
2124
+ display.lastSentHeaderTitle = activityHeaderTitle;
2125
+ const deltaCard = buildProgressCard(progressView({ text: truncateContent(displayContent) || "等待 Agent 输出...", showStop: true, headerTitle: activityHeaderTitle }));
2125
2126
  display.cardBusy = true;
2126
2127
  const mySeq = display.sequence + 1;
2127
2128
  try {
@@ -2140,24 +2141,24 @@ export function startUnifiedDisplayLoop(): void {
2140
2141
  display.cardBusy = false;
2141
2142
  }
2142
2143
  continue;
2143
- }
2144
-
2145
- display.dotCount = (display.dotCount % 9) + 1;
2146
- let contentBase = state.accumulatedContent + state.finalReply;
2147
- if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
2148
- const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
2149
- if (
2150
- fullContent === display.lastSentContent
2151
- && activityHeaderTitle === display.lastSentHeaderTitle
2152
- ) continue;
2153
-
2154
- display.lastSentContent = fullContent;
2155
- display.lastSentHeaderTitle = activityHeaderTitle;
2156
- const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
2144
+ }
2145
+
2146
+ display.dotCount = (display.dotCount % 9) + 1;
2147
+ let contentBase = state.accumulatedContent + state.finalReply;
2148
+ if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
2149
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
2150
+ if (
2151
+ fullContent === display.lastSentContent
2152
+ && activityHeaderTitle === display.lastSentHeaderTitle
2153
+ ) continue;
2154
+
2155
+ display.lastSentContent = fullContent;
2156
+ display.lastSentHeaderTitle = activityHeaderTitle;
2157
+ const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
2157
2158
  display.cardBusy = true;
2158
2159
  const mySeq = display.sequence + 1;
2159
2160
  try {
2160
- const card = buildProgressCard(cardContent, { showStop: true, headerTitle: activityHeaderTitle });
2161
+ const card = buildProgressCard(progressView({ text: cardContent, showStop: true, headerTitle: activityHeaderTitle }));
2161
2162
  await p.cardUpdate(display.cardId, card, mySeq);
2162
2163
  display.sequence = mySeq;
2163
2164
  } catch (err) {
@@ -2214,37 +2215,37 @@ export function stopUnifiedDisplayLoop(): void {
2214
2215
  // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
2215
2216
  // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
2216
2217
  // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
2217
- export function stopSession(sessionId: string): boolean {
2218
- // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2219
- // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
2220
- const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
2221
- const prompt = activePrompts.get(sessionId);
2222
- if (!prompt) {
2223
- if (cancelledRecovery) {
2224
- cancelQueuedMessage(sessionId);
2225
- console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
2226
- return true;
2227
- }
2228
- return false;
2229
- }
2230
- prompt.stopped = true;
2231
- clearPromptResponseStallMonitor(sessionId);
2232
- clearPromptProcessMonitor(sessionId);
2233
- clearPromptFinalResponseCloseTimer(sessionId);
2234
- cancelQueuedMessage(sessionId);
2235
-
2236
- // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
2237
- // cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
2238
- // 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
2239
- // killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
2240
- void killProcessTree(prompt.processPid);
2241
- try {
2242
- prompt.closeSession?.();
2243
- } catch (err) {
2218
+ export function stopSession(sessionId: string): boolean {
2219
+ // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2220
+ // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
2221
+ const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
2222
+ const prompt = activePrompts.get(sessionId);
2223
+ if (!prompt) {
2224
+ if (cancelledRecovery) {
2225
+ cancelQueuedMessage(sessionId);
2226
+ console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
2227
+ return true;
2228
+ }
2229
+ return false;
2230
+ }
2231
+ prompt.stopped = true;
2232
+ clearPromptResponseStallMonitor(sessionId);
2233
+ clearPromptProcessMonitor(sessionId);
2234
+ clearPromptFinalResponseCloseTimer(sessionId);
2235
+ cancelQueuedMessage(sessionId);
2236
+
2237
+ // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
2238
+ // cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
2239
+ // 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
2240
+ // killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
2241
+ void killProcessTree(prompt.processPid);
2242
+ try {
2243
+ prompt.closeSession?.();
2244
+ } catch (err) {
2244
2245
  console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
2245
- }
2246
- prompt.controller.abort();
2247
- console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2246
+ }
2247
+ prompt.controller.abort();
2248
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2248
2249
 
2249
2250
  // fire-and-forget:立刻把 stream-state.status 改成 stopped,
2250
2251
  // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
@@ -2314,27 +2315,27 @@ async function resolveModelEffort(
2314
2315
  // adapter 异常时降级为占位符(不阻塞 /state 卡片)
2315
2316
  }
2316
2317
  return { model, effort: null };
2317
- }
2318
- if (tool === "codex") {
2319
- const m = getEffectiveModelForTool(tool, sessionId);
2320
- const e = getEffectiveEffortForTool(tool, sessionId);
2321
- return {
2322
- model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2323
- effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
2324
- };
2325
- }
2326
- if (tool === "ccc") {
2327
- const m = getEffectiveModelForTool(tool, sessionId);
2328
- return {
2329
- model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2330
- effort: null,
2331
- };
2332
- }
2333
- return {
2334
- model: anthropicConfigDisplay(getModelForSession(sessionId)),
2335
- effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
2336
- };
2337
- }
2318
+ }
2319
+ if (tool === "codex") {
2320
+ const m = getEffectiveModelForTool(tool, sessionId);
2321
+ const e = getEffectiveEffortForTool(tool, sessionId);
2322
+ return {
2323
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2324
+ effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
2325
+ };
2326
+ }
2327
+ if (tool === "ccc") {
2328
+ const m = getEffectiveModelForTool(tool, sessionId);
2329
+ return {
2330
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2331
+ effort: null,
2332
+ };
2333
+ }
2334
+ return {
2335
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
2336
+ effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
2337
+ };
2338
+ }
2338
2339
 
2339
2340
  export async function getSessionStatus(chatId: string): Promise<SessionStatus | null> {
2340
2341
  const info = sessionInfoMap.get(chatId);
@@ -2367,10 +2368,10 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
2367
2368
  };
2368
2369
  }
2369
2370
 
2370
- export interface SessionsListEntry {
2371
- chatId: string;
2372
- chatType?: string;
2373
- sessionId: string;
2371
+ export interface SessionsListEntry {
2372
+ chatId: string;
2373
+ chatType?: string;
2374
+ sessionId: string;
2374
2375
  chatName: string;
2375
2376
  active: boolean;
2376
2377
  turnCount: number;
@@ -2393,10 +2394,10 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2393
2394
  .map(([sessionId, record]) => {
2394
2395
  const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
2395
2396
  const active = activePrompts.get(sessionId);
2396
- return {
2397
- chatId: "",
2398
- chatType: undefined,
2399
- sessionId,
2397
+ return {
2398
+ chatId: "",
2399
+ chatType: undefined,
2400
+ sessionId,
2400
2401
  tool: record.tool,
2401
2402
  chatName: record.chatName ?? "",
2402
2403
  turnCount: 0,
@@ -2414,10 +2415,10 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2414
2415
  return Promise.all(
2415
2416
  entries.map(async (info) => {
2416
2417
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
2417
- return {
2418
- chatId: info.chatId,
2419
- chatType: info.chatType,
2420
- sessionId: info.sessionId,
2418
+ return {
2419
+ chatId: info.chatId,
2420
+ chatType: info.chatType,
2421
+ sessionId: info.sessionId,
2421
2422
  chatName: info.chatName || "",
2422
2423
  active: !!activePrompts.get(info.sessionId) &&
2423
2424
  !activePrompts.get(info.sessionId)?.stopped &&
@@ -2442,11 +2443,11 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2442
2443
  export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
2443
2444
  adapterCache.set(tool, adapter);
2444
2445
  // 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
2445
- const effective = getEffectiveModelForTool(tool);
2446
- const effort = getEffectiveEffortForTool(tool);
2447
- const fastMode = getEffectiveFastModeForTool(tool);
2448
- adapterCache.set(`${tool}:${effective || ""}:${effort || ""}:${fastMode ? "fast" : "default"}`, adapter);
2449
- if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
2446
+ const effective = getEffectiveModelForTool(tool);
2447
+ const effort = getEffectiveEffortForTool(tool);
2448
+ const fastMode = getEffectiveFastModeForTool(tool);
2449
+ adapterCache.set(`${tool}:${effective || ""}:${effort || ""}:${fastMode ? "fast" : "default"}`, adapter);
2450
+ if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
2450
2451
  }
2451
2452
 
2452
2453
  export function clearAdapterCache(): void {