chatccc 0.2.222 → 0.2.223

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") {
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,53 @@ 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({ model: effectiveModel || undefined });
601
+ } else {
602
+ adapter = createClaudeAdapter({
603
+ model: effectiveModel,
604
+ subagentModel: CLAUDE_SUBAGENT_MODEL,
605
+ effort: effectiveEffort,
606
+ apiKey: CLAUDE_API_KEY,
607
+ baseUrl: CLAUDE_BASE_URL,
608
+ isEmpty: isAnthropicConfigEmpty,
608
609
  maxTurn: CLAUDE_MAX_TURN,
609
610
  });
610
611
  }
@@ -676,13 +677,13 @@ export function _resetSessionToolsFileForTest(): void {
676
677
  export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
677
678
  let sessionRegistryFile = SESSION_REGISTRY_FILE;
678
679
 
679
- export interface SessionRegistryUpdate {
680
- chatId: string;
681
- sessionId: string;
682
- tool: string;
683
- /** 会话容器类型;旧 registry 没有该字段,读取时必须兼容。 */
684
- chatType?: string;
685
- chatName?: string;
680
+ export interface SessionRegistryUpdate {
681
+ chatId: string;
682
+ sessionId: string;
683
+ tool: string;
684
+ /** 会话容器类型;旧 registry 没有该字段,读取时必须兼容。 */
685
+ chatType?: string;
686
+ chatName?: string;
686
687
  turnCount?: number;
687
688
  lastContextTokens?: number;
688
689
  startTime?: number;
@@ -690,12 +691,12 @@ export interface SessionRegistryUpdate {
690
691
  running?: boolean;
691
692
  }
692
693
 
693
- interface SessionRegistryRecord {
694
- chatId: string;
695
- sessionId: string;
696
- tool: string;
697
- chatType?: string;
698
- chatName: string;
694
+ interface SessionRegistryRecord {
695
+ chatId: string;
696
+ sessionId: string;
697
+ tool: string;
698
+ chatType?: string;
699
+ chatName: string;
699
700
  turnCount: number;
700
701
  lastContextTokens: number;
701
702
  startTime: number;
@@ -756,11 +757,11 @@ export async function recordSessionRegistry(update: SessionRegistryUpdate): Prom
756
757
  const now = update.updatedAt ?? Date.now();
757
758
 
758
759
  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 ?? "",
760
+ chatId: update.chatId,
761
+ sessionId: update.sessionId,
762
+ tool: update.tool,
763
+ chatType: update.chatType ?? existing?.chatType,
764
+ chatName: update.chatName ?? existing?.chatName ?? "",
764
765
  turnCount: update.turnCount ?? existing?.turnCount ?? 0,
765
766
  lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
766
767
  startTime: update.startTime ?? existing?.startTime ?? now,
@@ -1009,12 +1010,12 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
1009
1010
 
1010
1011
  // Step 3: 持久化(registry + sessions.json)。
1011
1012
  // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
1012
- await recordSessionRegistry({
1013
- chatId,
1014
- sessionId: newSessionId,
1015
- tool,
1016
- chatType,
1017
- chatName,
1013
+ await recordSessionRegistry({
1014
+ chatId,
1015
+ sessionId: newSessionId,
1016
+ tool,
1017
+ chatType,
1018
+ chatName,
1018
1019
  turnCount: initialTurnCount,
1019
1020
  lastContextTokens: initialContextTokens,
1020
1021
  startTime: now,
@@ -1037,58 +1038,58 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
1037
1038
  function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
1038
1039
  if (tool === "cursor") {
1039
1040
  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);
1041
+ }
1042
+ if (tool === "codex") {
1043
+ const m = getEffectiveModelForTool(tool, sessionId);
1044
+ const e = getEffectiveEffortForTool(tool, sessionId);
1045
+ const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
1046
+ const effortStr = e.trim() !== ""
1047
+ ? `effort=${e}`
1048
+ : "effort=(由 codex config.toml 决定)";
1049
+ return `model=${modelStr}, ${effortStr}, fast=${getEffectiveFastModeForTool(tool, sessionId) ? "on" : "off"}`;
1050
+ }
1051
+ if (tool === "ccc") {
1052
+ const m = getEffectiveModelForTool(tool, sessionId);
1053
+ const modelStr = m.trim() !== "" ? m : "(not configured)";
1054
+ return `model=${modelStr}, baseURL=${config.ccc.DEEPSEEK_BASE_URL}`;
1055
+ }
1056
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
1057
+ }
1058
+
1059
+ export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
1060
+ const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
1061
+ const adapter = getAdapterForTool(tool);
1061
1062
  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;
1063
+ `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
1064
+ );
1065
+
1066
+ // Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
1067
+ // 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
1068
+ // 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
1069
+ const createController = new AbortController();
1070
+ let createTimeout: ReturnType<typeof setTimeout> | undefined;
1071
+ const timeoutError = new Error(
1072
+ `${adapter.displayName} session creation timed out after 3 minutes without an init event`,
1073
+ );
1074
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1075
+ createTimeout = setTimeout(() => {
1076
+ // 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
1077
+ reject(timeoutError);
1078
+ createController.abort();
1079
+ }, responseStallTimeoutMs);
1080
+ createTimeout.unref?.();
1081
+ });
1082
+
1083
+ let result: Awaited<ReturnType<ToolAdapter["createSession"]>>;
1084
+ try {
1085
+ result = await Promise.race([
1086
+ adapter.createSession(cwd, createController.signal),
1087
+ timeoutPromise,
1088
+ ]);
1089
+ } finally {
1090
+ if (createTimeout) clearTimeout(createTimeout);
1091
+ }
1092
+ const sessionId = result.sessionId;
1092
1093
  console.log(`[${ts()}] → sessionId: ${sessionId}`);
1093
1094
 
1094
1095
  await saveSessionTool(sessionId, tool);
@@ -1110,41 +1111,41 @@ export async function resumeAndPrompt(
1110
1111
  return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
1111
1112
  }
1112
1113
 
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 只推送到该群)
1114
+ // ---------------------------------------------------------------------------
1115
+ // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
1116
+ // ---------------------------------------------------------------------------
1117
+
1118
+ interface RunAgentSessionOptions {
1119
+ /**
1120
+ * 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
1121
+ * 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
1122
+ */
1123
+ autoRecovery?: boolean;
1124
+ }
1125
+
1126
+ export async function runAgentSession(
1127
+ sessionId: string,
1128
+ userText: string,
1129
+ platform: PlatformAdapter,
1130
+ _chatId: string,
1131
+ msgTimestamp: number,
1132
+ tool: string,
1133
+ traceId?: string,
1134
+ options: RunAgentSessionOptions = {},
1135
+ ): Promise<void> {
1136
+ const tid = traceId ?? "";
1137
+
1138
+ // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
1139
+ // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
1140
+ // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
1141
+ // 相同的卡片、状态和收尾行为。
1142
+ const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
1143
+ if (previousSessionId && previousSessionId !== sessionId) {
1144
+ unbindChatFromSession(previousSessionId, _chatId);
1145
+ }
1146
+ bindChatToSession(sessionId, _chatId);
1147
+
1148
+ // 记录用户最后发送消息的群(display loop 只推送到该群)
1148
1149
  // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
1149
1150
  recordChatPlatform(_chatId, platform);
1150
1151
  recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
@@ -1165,19 +1166,19 @@ export async function runAgentSession(
1165
1166
  // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
1166
1167
  const controller = new AbortController();
1167
1168
  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
- });
1169
+ activePrompts.set(sessionId, {
1170
+ controller,
1171
+ stopped: false,
1172
+ startTime: now,
1173
+ autoRecovery: options.autoRecovery === true,
1174
+ finalResponseObserved: false,
1175
+ });
1175
1176
 
1176
1177
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
1177
1178
  const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
1178
1179
  if (data.sessionId !== sessionId) return;
1179
1180
  const prompt = activePrompts.get(sessionId);
1180
- if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
1181
+ if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
1181
1182
  prompt.resourceStuck = true;
1182
1183
 
1183
1184
  const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
@@ -1214,12 +1215,12 @@ export async function runAgentSession(
1214
1215
  const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
1215
1216
  const skillVariables = {
1216
1217
  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"),
1218
+ session_id: sessionId,
1219
+ im_skills_cache_dir: imSkillsCacheDir,
1220
+ delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
1221
+ send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
1222
+ send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
1223
+ send_image_script: join(feishuSkillDir, "send-image.mjs"),
1223
1224
  send_file_script: join(feishuSkillDir, "send-file.mjs"),
1224
1225
  download_video_script: join(feishuSkillDir, "download-video.mjs"),
1225
1226
  wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
@@ -1284,10 +1285,10 @@ export async function runAgentSession(
1284
1285
  // 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
1285
1286
  // 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
1286
1287
  // 再开始缓存问题对应的任务"。
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);
1288
+ const prevState = await readStreamState(sessionId);
1289
+ if (prevState && prevState.status !== "running") {
1290
+ const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
1291
+ const displayChatId = pickDisplayChat(sessionId);
1291
1292
  if (displayChatId) {
1292
1293
  const pp = platformForChat(displayChatId);
1293
1294
  const display = displayCards.get(displayChatId);
@@ -1302,12 +1303,12 @@ export async function runAgentSession(
1302
1303
  if (displayCards.get(displayChatId) !== display) {
1303
1304
  const finalStatus = turnFinalStatus(prevState.status);
1304
1305
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1305
- setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1306
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1306
1307
  } else {
1307
1308
  const nextSeq = display.sequence + 1;
1308
1309
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
1309
1310
  const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
1310
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1311
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1311
1312
  await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1312
1313
  console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${(err as Error).message}`);
1313
1314
  });
@@ -1319,49 +1320,49 @@ export async function runAgentSession(
1319
1320
  const finalStatus = turnFinalStatus(prevState.status);
1320
1321
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1321
1322
 
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(() => {});
1323
+ if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1324
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1325
+ }
1326
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1326
1327
  }
1327
- } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1328
+ } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1328
1329
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
1329
1330
  const finalStatus = turnFinalStatus(prevState.status);
1330
1331
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1331
- await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1332
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1332
1333
  }
1333
1334
  // else: displayCards 无记录且无 finalReply → 无需处理
1334
1335
  }
1335
1336
  }
1336
1337
 
1337
1338
  // 初始化 stream-state.json
1338
- const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1339
- const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1340
- await writeStreamState(initialState);
1339
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1340
+ const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1341
+ await writeStreamState(initialState);
1341
1342
 
1342
1343
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
1343
1344
  // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
1344
1345
  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(
1346
+ if (displayChatIdForNew) {
1347
+ const ppNew = platformForChat(displayChatIdForNew);
1348
+ if (ppNew && ppNew.kind !== "wechat") {
1349
+ const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1350
+ const cardId = await createVisibleProgressCard(
1350
1351
  ppNew,
1351
1352
  displayChatIdForNew,
1352
1353
  sessionId,
1353
- nextTurnCount,
1354
- "生成中卡片发送失败,结果将以文本形式发送。",
1355
- initialHeaderTitle,
1354
+ nextTurnCount,
1355
+ "生成中卡片发送失败,结果将以文本形式发送。",
1356
+ initialHeaderTitle,
1356
1357
  );
1357
1358
  if (cardId) {
1358
1359
  displayCards.set(displayChatIdForNew, {
1359
1360
  cardId,
1360
1361
  sequence: 1,
1361
1362
  cardBusy: false,
1362
- cardCreatedAt: Date.now(),
1363
- lastSentContent: "",
1364
- lastSentHeaderTitle: initialHeaderTitle,
1363
+ cardCreatedAt: Date.now(),
1364
+ lastSentContent: "",
1365
+ lastSentHeaderTitle: initialHeaderTitle,
1365
1366
  streamErrorNotified: false,
1366
1367
  sessionId,
1367
1368
  turnCount: nextTurnCount,
@@ -1387,7 +1388,7 @@ export async function runAgentSession(
1387
1388
  // 设置最后活跃群头像为 busy
1388
1389
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1389
1390
  if (activeCid) {
1390
- setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1391
+ setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1391
1392
  }
1392
1393
 
1393
1394
  const state: AccumulatorState = {
@@ -1397,106 +1398,106 @@ export async function runAgentSession(
1397
1398
  chunkCount: 0,
1398
1399
  };
1399
1400
 
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, {
1401
+ let lastFileWrite = Date.now();
1402
+ const FILE_WRITE_INTERVAL_MS = 2000;
1403
+ const toolCallMap = new Map<string, { name: string; input: unknown }>();
1404
+ let streamErrored = false;
1405
+
1406
+ const runningPrompt = activePrompts.get(sessionId);
1407
+ if (runningPrompt) {
1408
+ // 必须在消费第一个事件前建立零字符基线。部分 CLI 卡死时只启动了进程,
1409
+ // 甚至一个事件都不会 yield;若等循环体更新进度,这种会话会永久停在
1410
+ // “正在启动 Agent”,也永远触发不了三分钟保护。
1411
+ runningPrompt.responseProgress = observeResponseProgress(
1412
+ undefined,
1413
+ true,
1414
+ 0,
1415
+ activityTracker.activity.startedAt,
1416
+ );
1417
+
1418
+ const checkResponseStall = async () => {
1419
+ const current = activePrompts.get(sessionId);
1420
+ if (!current || current !== runningPrompt) {
1421
+ clearPromptResponseStallMonitor(sessionId);
1422
+ return;
1423
+ }
1424
+ if (
1425
+ current.stopped
1426
+ || current.abnormalExit
1427
+ || current.resourceStuck
1428
+ || current.autoEnded
1429
+ || current.finalResponseObserved
1430
+ || !monitorsOutputProgress(activityTracker.activity.kind)
1431
+ || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1432
+ ) {
1433
+ return;
1434
+ }
1435
+
1436
+ const autoEndedAt = Date.now();
1437
+ // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1438
+ // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1439
+ // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1440
+ // 若当前已经是恢复轮,则不再预约第三轮。
1441
+ if (!current.autoRecovery) {
1442
+ reserveAutoRecovery(sessionId);
1443
+ }
1444
+ current.autoEnded = true;
1445
+ current.autoEndedAt = autoEndedAt;
1446
+ clearPromptResponseStallMonitor(sessionId);
1447
+ clearPromptProcessMonitor(sessionId);
1448
+
1449
+ // First publish an atomic terminal state so the card cannot keep claiming the
1450
+ // Agent is running while process cleanup is underway.
1451
+ await writeStreamState({
1452
+ sessionId,
1453
+ status: "auto_ended",
1454
+ accumulatedContent: state.accumulatedContent,
1455
+ finalReply: pickFinalReply(state).trim(),
1456
+ activity: activityTracker.activity,
1457
+ chunkCount: state.chunkCount,
1458
+ turnCount: nextTurnCount,
1459
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1460
+ updatedAt: autoEndedAt,
1461
+ cwd,
1462
+ tool,
1463
+ autoEndedAt,
1464
+ });
1465
+
1466
+ // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1467
+ // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1468
+ if (current.finalResponseObserved) {
1469
+ current.autoEnded = false;
1470
+ current.autoEndedAt = undefined;
1471
+ cancelAutoRecoveryReservation(sessionId);
1472
+ console.log(
1473
+ `[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
1474
+ );
1475
+ return;
1476
+ }
1477
+
1478
+ try {
1479
+ current.closeSession?.();
1480
+ } catch (err) {
1481
+ console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
1482
+ }
1483
+ current.controller.abort();
1484
+ await killProcessTree(current.processPid);
1485
+ console.warn(
1486
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without startup or reply progress`,
1487
+ );
1488
+ };
1489
+
1490
+ const responseStallMonitor = setInterval(() => {
1491
+ void checkResponseStall().catch((err) => {
1492
+ console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
1493
+ });
1494
+ }, responseStallCheckIntervalMs);
1495
+ responseStallMonitor.unref?.();
1496
+ runningPrompt.responseStallMonitor = responseStallMonitor;
1497
+ }
1498
+
1499
+ try {
1500
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1500
1501
  onProcessStart: (processInfo) => {
1501
1502
  startPromptProcessMonitor(sessionId, processInfo);
1502
1503
  if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
@@ -1505,27 +1506,27 @@ export async function runAgentSession(
1505
1506
  clearPromptProcessMonitor(sessionId);
1506
1507
  if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1507
1508
  },
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);
1509
+ onSessionCreated: (closeSession) => {
1510
+ const prompt = activePrompts.get(sessionId);
1511
+ if (prompt) prompt.closeSession = closeSession;
1512
+ },
1513
+ })) {
1514
+ if (unifiedMsg.isFinalResponse) {
1515
+ const prompt = activePrompts.get(sessionId);
1516
+ if (prompt && prompt === runningPrompt) {
1517
+ // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1518
+ // 最终事件后仍把本轮判为停滞。
1519
+ if (!prompt.finalResponseObserved) {
1520
+ prompt.finalResponseObserved = true;
1521
+ scheduleFinalResponseCloseGuard(sessionId, prompt);
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ let activityChanged = false;
1527
+ for (const block of unifiedMsg.blocks) {
1528
+ if (updateAgentActivity(activityTracker, block)) activityChanged = true;
1529
+ accumulateBlockContent(block, state, toolCallMap);
1529
1530
 
1530
1531
  if (block.type === "compact_boundary" && block.post_tokens) {
1531
1532
  for (const cid of getChatsForSession(sessionId)) {
@@ -1539,32 +1540,32 @@ export async function runAgentSession(
1539
1540
  lastContextTokens: block.post_tokens,
1540
1541
  running: true,
1541
1542
  });
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
- // 定时写入文件
1543
+ }
1544
+ }
1545
+
1546
+ const prompt = activePrompts.get(sessionId);
1547
+ if (prompt && !prompt.autoEnded) {
1548
+ const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1549
+ prompt.responseProgress = observeResponseProgress(
1550
+ // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1551
+ // 重新计时;其它活动阶段会清空观察窗口。
1552
+ activityChanged ? undefined : prompt.responseProgress,
1553
+ monitorsOutputProgress(activityTracker.activity.kind),
1554
+ totalChars,
1555
+ Date.now(),
1556
+ );
1557
+ }
1558
+
1559
+ // 定时写入文件
1559
1560
  const now2 = Date.now();
1560
- if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1561
- lastFileWrite = now2;
1562
- await writeStreamState({
1561
+ if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1562
+ lastFileWrite = now2;
1563
+ await writeStreamState({
1563
1564
  sessionId,
1564
1565
  status: "running",
1565
- accumulatedContent: state.accumulatedContent,
1566
- finalReply: pickFinalReply(state),
1567
- activity: activityTracker.activity,
1566
+ accumulatedContent: state.accumulatedContent,
1567
+ finalReply: pickFinalReply(state),
1568
+ activity: activityTracker.activity,
1568
1569
  chunkCount: state.chunkCount,
1569
1570
  turnCount: nextTurnCount,
1570
1571
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1573,58 +1574,58 @@ export async function runAgentSession(
1573
1574
  tool,
1574
1575
  });
1575
1576
  }
1576
- }
1577
- } catch (streamErr) {
1578
- streamErrored = true;
1579
- console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1580
- } finally {
1577
+ }
1578
+ } catch (streamErr) {
1579
+ streamErrored = true;
1580
+ console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1581
+ } finally {
1581
1582
  // 标记 prompt 结束
1582
1583
  resourceMonitor.off("stuck", onResourceStuck);
1583
1584
  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();
1585
+ const wasStopped = prompt?.stopped ?? false;
1586
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1587
+ const wasResourceStuck = prompt?.resourceStuck ?? false;
1588
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1589
+ const completedAtTimeoutBoundary =
1590
+ timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1591
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1592
+ const wasAutoRecovery = prompt?.autoRecovery ?? false;
1593
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1594
+ clearPromptResponseStallMonitor(sessionId);
1595
+ clearPromptProcessMonitor(sessionId);
1596
+ clearPromptFinalResponseCloseTimer(sessionId);
1597
+ markSessionFinalizing(sessionId);
1598
+ activePrompts.delete(sessionId);
1599
+
1600
+ try {
1601
+ if (completedAtTimeoutBoundary) {
1602
+ // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1603
+ // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1604
+ cancelAutoRecoveryReservation(sessionId);
1605
+ console.log(
1606
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
1607
+ );
1608
+ }
1609
+ // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1610
+ // 永久残留 running=true。
1611
+ const finalizationChatIds = [...new Set([
1612
+ ...getChatsForSession(sessionId),
1613
+ _chatId,
1614
+ ])];
1615
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1616
+ // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1617
+ // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1618
+ // 运行中并更新旧卡片,而不是新建卡片。
1619
+ const finalStatus = completedAtTimeoutBoundary
1620
+ ? "done"
1621
+ : wasAutoEnded
1622
+ ? "auto_ended"
1623
+ : (streamErrored || wasAbnormalExit || wasResourceStuck)
1624
+ ? "error"
1625
+ : wasStopped
1626
+ ? "stopped"
1627
+ : "done";
1628
+ const finalReply = pickFinalReply(state).trim();
1628
1629
 
1629
1630
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1630
1631
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1644,25 +1645,25 @@ export async function runAgentSession(
1644
1645
  await writeStreamState({
1645
1646
  sessionId,
1646
1647
  status: finalStatus,
1647
- accumulatedContent: state.accumulatedContent,
1648
- finalReply: finalReplyToWrite,
1649
- activity: activityTracker.activity,
1648
+ accumulatedContent: state.accumulatedContent,
1649
+ finalReply: finalReplyToWrite,
1650
+ activity: activityTracker.activity,
1650
1651
  chunkCount: state.chunkCount,
1651
1652
  turnCount: nextTurnCount,
1652
1653
  contextTokens: existingInfo?.lastContextTokens ?? 0,
1653
1654
  updatedAt: Date.now(),
1654
1655
  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) {
1656
+ tool,
1657
+ ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1658
+ ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1659
+ });
1660
+
1661
+ // display loop 下一轮会读到最终状态并发送消息
1662
+
1663
+ let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1664
+
1665
+ if (wasStopped) {
1666
+ for (const cid of finalizationChatIds) {
1666
1667
  const finfo = sessionInfoMap.get(cid);
1667
1668
  await recordSessionRegistry({
1668
1669
  chatId: cid,
@@ -1674,62 +1675,62 @@ export async function runAgentSession(
1674
1675
  running: false,
1675
1676
  });
1676
1677
  }
1677
- const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1678
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1678
1679
  if (active1) {
1679
1680
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1680
- setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1681
+ setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1681
1682
  }
1682
1683
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1683
1684
  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) {
1685
+ } else if (wasAutoEnded) {
1686
+ for (const cid of finalizationChatIds) {
1687
+ const finfo = sessionInfoMap.get(cid);
1688
+ await recordSessionRegistry({
1689
+ chatId: cid,
1690
+ sessionId,
1691
+ tool,
1692
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1693
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1694
+ startTime: finfo?.startTime ?? now,
1695
+ running: false,
1696
+ });
1697
+ }
1698
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1699
+ if (activeAutoEnded) {
1700
+ const pp = platformForChat(activeAutoEnded) ?? platform;
1701
+ const terminalState = await readStreamState(sessionId);
1702
+ if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1703
+ await sendFinalReplyTextOnce(
1704
+ pp,
1705
+ activeAutoEnded,
1706
+ sessionId,
1707
+ nextTurnCount,
1708
+ formatAutoEndedReply(finalReplyToWrite),
1709
+ );
1710
+ }
1711
+ setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
1712
+
1713
+ if (wasAutoRecovery) {
1714
+ // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1715
+ // 自动链,避免第三轮及之后的无限续跑。
1716
+ await pp.sendText(
1717
+ activeAutoEnded,
1718
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1719
+ ).catch(() => {});
1720
+ } else if (hasAutoRecoveryReservation(sessionId)) {
1721
+ // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1722
+ // 内部恢复仍由 reservation 保证先于普通缓存消息。
1723
+ await pp.sendText(
1724
+ activeAutoEnded,
1725
+ RESPONSE_STALL_RECOVERY_NOTICE,
1726
+ ).catch(() => {});
1727
+ autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1728
+ }
1729
+ }
1730
+ console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled startup or response output (content chunks: ${state.chunkCount})`);
1731
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1732
+ } else if (wasAbnormalExit) {
1733
+ for (const cid of finalizationChatIds) {
1733
1734
  const finfo = sessionInfoMap.get(cid);
1734
1735
  await recordSessionRegistry({
1735
1736
  chatId: cid,
@@ -1741,12 +1742,12 @@ export async function runAgentSession(
1741
1742
  running: false,
1742
1743
  });
1743
1744
  }
1744
- const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1745
- if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1745
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1746
+ if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1746
1747
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1747
1748
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1748
- } else {
1749
- for (const cid of finalizationChatIds) {
1749
+ } else {
1750
+ for (const cid of finalizationChatIds) {
1750
1751
  const finfo = sessionInfoMap.get(cid);
1751
1752
  await recordSessionRegistry({
1752
1753
  chatId: cid,
@@ -1758,107 +1759,107 @@ export async function runAgentSession(
1758
1759
  running: false,
1759
1760
  });
1760
1761
  }
1761
- const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1762
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1762
1763
  if (active2) {
1763
1764
  const terminalState = await readStreamState(sessionId);
1764
1765
  if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1765
1766
  const pp = platformForChat(active2) ?? platform;
1766
1767
  await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1767
1768
  }
1768
- setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1769
+ setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1770
+ }
1771
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1772
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1773
+ }
1774
+
1775
+ // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1776
+ if (
1777
+ wasAutoEnded
1778
+ && hasAutoRecoveryReservation(sessionId)
1779
+ && !autoRecoveryTarget
1780
+ ) {
1781
+ cancelAutoRecoveryReservation(sessionId);
1782
+ }
1783
+ const shouldScheduleAutoRecovery =
1784
+ autoRecoveryTarget !== undefined
1785
+ && hasAutoRecoveryReservation(sessionId);
1786
+
1787
+ // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1788
+ // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1789
+ let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
1790
+ if (wasStopped) {
1791
+ const discarded = dequeueMessage(sessionId);
1792
+ if (discarded) {
1793
+ console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1794
+ }
1795
+ } else if (!shouldScheduleAutoRecovery) {
1796
+ // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1797
+ // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1798
+ queuedForConsumption = dequeueMessage(sessionId);
1799
+ }
1800
+
1801
+ if (queuedForConsumption) {
1802
+ const queued = queuedForConsumption;
1803
+ // 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
1804
+ // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1805
+ // 用保存的 chat 替代 queued.chatId 作为 display 目标)。
1806
+ const preservedChat = getLastActiveChat(sessionId);
1807
+ if (preservedChat && preservedChat !== queued.chatId) {
1808
+ setQueuePreservedChat(sessionId, preservedChat);
1769
1809
  }
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
- }
1810
+ console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1811
+ // setTimeout 而非 setImmediate:给 display loop setInterval
1812
+ // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1813
+ setTimeout(() => {
1814
+ consumeQueuedMessage(platform, queued);
1815
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1816
+ }
1817
+
1818
+ if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1819
+ const target = autoRecoveryTarget;
1820
+ console.log(
1821
+ `[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
1822
+ );
1823
+ // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1824
+ // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1825
+ // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1826
+ // 因而不存在普通用户消息可插入的事件循环空窗。
1827
+ setTimeout(() => {
1828
+ if (!hasAutoRecoveryReservation(sessionId)) return;
1829
+ const recoveryRun = runAgentSession(
1830
+ sessionId,
1831
+ RESPONSE_STALL_RECOVERY_PROMPT,
1832
+ target.platform,
1833
+ target.chatId,
1834
+ Date.now(),
1835
+ tool,
1836
+ undefined,
1837
+ { autoRecovery: true },
1838
+ );
1839
+ consumeAutoRecoveryReservation(sessionId);
1840
+ void recoveryRun.catch((err) => {
1841
+ console.error(
1842
+ `[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
1843
+ );
1844
+ target.platform.sendText(
1845
+ target.chatId,
1846
+ `⚠️ 自动续跑启动失败:${(err as Error).message}`,
1847
+ ).catch(() => {});
1848
+
1849
+ // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1850
+ // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1851
+ const queued = dequeueMessage(sessionId);
1852
+ if (queued) {
1853
+ consumeQueuedMessage(target.platform, queued);
1854
+ }
1855
+ });
1856
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1857
+ }
1858
+ } finally {
1859
+ clearSessionFinalizing(sessionId);
1860
+ }
1861
+ }
1862
+ }
1862
1863
 
1863
1864
  // ---------------------------------------------------------------------------
1864
1865
  // startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
@@ -1932,14 +1933,14 @@ export function startUnifiedDisplayLoop(): void {
1932
1933
  // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1933
1934
  // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1934
1935
  // 下一次 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;
1936
+ if (activePrompts.has(sessionId)) continue;
1937
+
1938
+ const tail = "━━━ 回答结束 ━━━";
1939
+ const finalMsg = state.status === "auto_ended"
1940
+ ? formatAutoEndedReply(remaining)
1941
+ : remaining
1942
+ ? remaining + "\n" + tail
1943
+ : tail;
1943
1944
  if (!isFinalReplySentForTurn(state)) {
1944
1945
  await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1945
1946
  }
@@ -1963,7 +1964,7 @@ export function startUnifiedDisplayLoop(): void {
1963
1964
  const nextSeq = display.sequence + 1;
1964
1965
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1965
1966
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1966
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1967
+ const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1967
1968
  await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1968
1969
  display.sequence = nextSeq;
1969
1970
  terminalCardUpdateAccepted = true;
@@ -1988,12 +1989,12 @@ export function startUnifiedDisplayLoop(): void {
1988
1989
  continue;
1989
1990
  }
1990
1991
 
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
- }
1992
+ let terminalTextDelivered = true;
1993
+ const terminalReply = formatTerminalReply(state.status, state.finalReply);
1994
+ if (terminalReply) {
1995
+ if (!isFinalReplySentForTurn(state)) {
1996
+ terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
1997
+ }
1997
1998
  } else if (state.accumulatedContent.trim()) {
1998
1999
  const short = truncateContent(state.accumulatedContent, 30, 4000);
1999
2000
  terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
@@ -2008,7 +2009,7 @@ export function startUnifiedDisplayLoop(): void {
2008
2009
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
2009
2010
  displayCards.delete(chatId);
2010
2011
  }
2011
- setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
2012
+ setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
2012
2013
  console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
2013
2014
  } else {
2014
2015
  // running: 创建或更新展示
@@ -2045,16 +2046,16 @@ export function startUnifiedDisplayLoop(): void {
2045
2046
  }
2046
2047
  } else {
2047
2048
  // 非 WeChat: 卡片流程
2048
- if (display.turnCount !== state.turnCount) {
2049
+ if (display.turnCount !== state.turnCount) {
2049
2050
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
2050
2051
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
2051
2052
  displayCards.delete(chatId);
2052
- continue;
2053
- }
2054
-
2055
- const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
2056
-
2057
- // 卡片轮转
2053
+ continue;
2054
+ }
2055
+
2056
+ const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
2057
+
2058
+ // 卡片轮转
2058
2059
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
2059
2060
  display.cardBusy = true;
2060
2061
  try {
@@ -2062,9 +2063,9 @@ export function startUnifiedDisplayLoop(): void {
2062
2063
  p,
2063
2064
  chatId,
2064
2065
  sessionId,
2065
- display.turnCount,
2066
- display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
2067
- activityHeaderTitle,
2066
+ display.turnCount,
2067
+ display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
2068
+ activityHeaderTitle,
2068
2069
  );
2069
2070
  if (!newCardId) {
2070
2071
  display.streamErrorNotified = true;
@@ -2072,7 +2073,7 @@ export function startUnifiedDisplayLoop(): void {
2072
2073
  }
2073
2074
  const oldSeqBase = display.sequence;
2074
2075
  const oldContent = state.accumulatedContent + state.finalReply;
2075
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "上一阶段记录" });
2076
+ const oldCard = buildProgressCard(progressView({ text: truncateContent(oldContent) || " ", status: "done", showStop: false, headerTitle: "上一阶段记录" }));
2076
2077
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
2077
2078
  display.sequence = oldSeqBase + 1;
2078
2079
  }).catch(err => {
@@ -2083,10 +2084,10 @@ export function startUnifiedDisplayLoop(): void {
2083
2084
  display.sequence = 1;
2084
2085
  display.cardCreatedAt = Date.now();
2085
2086
  display.rotationAccLen = state.accumulatedContent.length;
2086
- display.rotationFinalReply = state.finalReply;
2087
- display.lastSentContent = "";
2088
- display.lastSentHeaderTitle = activityHeaderTitle;
2089
- display.streamErrorNotified = false;
2087
+ display.rotationFinalReply = state.finalReply;
2088
+ display.lastSentContent = "";
2089
+ display.lastSentHeaderTitle = activityHeaderTitle;
2090
+ display.streamErrorNotified = false;
2090
2091
  } catch (err) {
2091
2092
  console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
2092
2093
  } finally {
@@ -2104,24 +2105,21 @@ export function startUnifiedDisplayLoop(): void {
2104
2105
  replyDelta = state.finalReply.slice(rotReply.length);
2105
2106
  } else {
2106
2107
  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
- });
2108
+ }
2109
+ const delta = (accDelta + replyDelta).trim();
2110
+
2111
+ display.dotCount = (display.dotCount % 9) + 1;
2112
+ let deltaBase = delta;
2113
+ if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
2114
+ const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
2115
+ if (
2116
+ displayContent === display.lastSentContent
2117
+ && activityHeaderTitle === display.lastSentHeaderTitle
2118
+ ) continue;
2119
+
2120
+ display.lastSentContent = displayContent;
2121
+ display.lastSentHeaderTitle = activityHeaderTitle;
2122
+ const deltaCard = buildProgressCard(progressView({ text: truncateContent(displayContent) || "等待 Agent 输出...", showStop: true, headerTitle: activityHeaderTitle }));
2125
2123
  display.cardBusy = true;
2126
2124
  const mySeq = display.sequence + 1;
2127
2125
  try {
@@ -2140,24 +2138,24 @@ export function startUnifiedDisplayLoop(): void {
2140
2138
  display.cardBusy = false;
2141
2139
  }
2142
2140
  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 输出...";
2141
+ }
2142
+
2143
+ display.dotCount = (display.dotCount % 9) + 1;
2144
+ let contentBase = state.accumulatedContent + state.finalReply;
2145
+ if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
2146
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
2147
+ if (
2148
+ fullContent === display.lastSentContent
2149
+ && activityHeaderTitle === display.lastSentHeaderTitle
2150
+ ) continue;
2151
+
2152
+ display.lastSentContent = fullContent;
2153
+ display.lastSentHeaderTitle = activityHeaderTitle;
2154
+ const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
2157
2155
  display.cardBusy = true;
2158
2156
  const mySeq = display.sequence + 1;
2159
2157
  try {
2160
- const card = buildProgressCard(cardContent, { showStop: true, headerTitle: activityHeaderTitle });
2158
+ const card = buildProgressCard(progressView({ text: cardContent, showStop: true, headerTitle: activityHeaderTitle }));
2161
2159
  await p.cardUpdate(display.cardId, card, mySeq);
2162
2160
  display.sequence = mySeq;
2163
2161
  } catch (err) {
@@ -2214,37 +2212,37 @@ export function stopUnifiedDisplayLoop(): void {
2214
2212
  // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
2215
2213
  // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
2216
2214
  // 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) {
2215
+ export function stopSession(sessionId: string): boolean {
2216
+ // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2217
+ // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
2218
+ const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
2219
+ const prompt = activePrompts.get(sessionId);
2220
+ if (!prompt) {
2221
+ if (cancelledRecovery) {
2222
+ cancelQueuedMessage(sessionId);
2223
+ console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
2224
+ return true;
2225
+ }
2226
+ return false;
2227
+ }
2228
+ prompt.stopped = true;
2229
+ clearPromptResponseStallMonitor(sessionId);
2230
+ clearPromptProcessMonitor(sessionId);
2231
+ clearPromptFinalResponseCloseTimer(sessionId);
2232
+ cancelQueuedMessage(sessionId);
2233
+
2234
+ // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
2235
+ // cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
2236
+ // 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
2237
+ // killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
2238
+ void killProcessTree(prompt.processPid);
2239
+ try {
2240
+ prompt.closeSession?.();
2241
+ } catch (err) {
2244
2242
  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`);
2243
+ }
2244
+ prompt.controller.abort();
2245
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2248
2246
 
2249
2247
  // fire-and-forget:立刻把 stream-state.status 改成 stopped,
2250
2248
  // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
@@ -2314,27 +2312,27 @@ async function resolveModelEffort(
2314
2312
  // adapter 异常时降级为占位符(不阻塞 /state 卡片)
2315
2313
  }
2316
2314
  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
- }
2315
+ }
2316
+ if (tool === "codex") {
2317
+ const m = getEffectiveModelForTool(tool, sessionId);
2318
+ const e = getEffectiveEffortForTool(tool, sessionId);
2319
+ return {
2320
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2321
+ effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
2322
+ };
2323
+ }
2324
+ if (tool === "ccc") {
2325
+ const m = getEffectiveModelForTool(tool, sessionId);
2326
+ return {
2327
+ model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
2328
+ effort: null,
2329
+ };
2330
+ }
2331
+ return {
2332
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
2333
+ effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
2334
+ };
2335
+ }
2338
2336
 
2339
2337
  export async function getSessionStatus(chatId: string): Promise<SessionStatus | null> {
2340
2338
  const info = sessionInfoMap.get(chatId);
@@ -2367,10 +2365,10 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
2367
2365
  };
2368
2366
  }
2369
2367
 
2370
- export interface SessionsListEntry {
2371
- chatId: string;
2372
- chatType?: string;
2373
- sessionId: string;
2368
+ export interface SessionsListEntry {
2369
+ chatId: string;
2370
+ chatType?: string;
2371
+ sessionId: string;
2374
2372
  chatName: string;
2375
2373
  active: boolean;
2376
2374
  turnCount: number;
@@ -2393,10 +2391,10 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2393
2391
  .map(([sessionId, record]) => {
2394
2392
  const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
2395
2393
  const active = activePrompts.get(sessionId);
2396
- return {
2397
- chatId: "",
2398
- chatType: undefined,
2399
- sessionId,
2394
+ return {
2395
+ chatId: "",
2396
+ chatType: undefined,
2397
+ sessionId,
2400
2398
  tool: record.tool,
2401
2399
  chatName: record.chatName ?? "",
2402
2400
  turnCount: 0,
@@ -2414,10 +2412,10 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2414
2412
  return Promise.all(
2415
2413
  entries.map(async (info) => {
2416
2414
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
2417
- return {
2418
- chatId: info.chatId,
2419
- chatType: info.chatType,
2420
- sessionId: info.sessionId,
2415
+ return {
2416
+ chatId: info.chatId,
2417
+ chatType: info.chatType,
2418
+ sessionId: info.sessionId,
2421
2419
  chatName: info.chatName || "",
2422
2420
  active: !!activePrompts.get(info.sessionId) &&
2423
2421
  !activePrompts.get(info.sessionId)?.stopped &&
@@ -2442,11 +2440,11 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
2442
2440
  export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
2443
2441
  adapterCache.set(tool, adapter);
2444
2442
  // 同时设置当前配置模型对应的 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);
2443
+ const effective = getEffectiveModelForTool(tool);
2444
+ const effort = getEffectiveEffortForTool(tool);
2445
+ const fastMode = getEffectiveFastModeForTool(tool);
2446
+ adapterCache.set(`${tool}:${effective || ""}:${effort || ""}:${fastMode ? "fast" : "default"}`, adapter);
2447
+ if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
2450
2448
  }
2451
2449
 
2452
2450
  export function clearAdapterCache(): void {