chatccc 0.2.251 → 0.2.253
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/cccagent.mjs +12 -3
- package/bin/chatccc.mjs +15 -6
- package/deepccc-agent/package.json +1 -1
- package/dist/deepccc-agent/src/cli.js +634 -0
- package/dist/deepccc-agent/src/config.js +76 -0
- package/dist/deepccc-agent/src/context.js +348 -0
- package/dist/deepccc-agent/src/file-log.js +34 -0
- package/dist/deepccc-agent/src/file-tools.js +1208 -0
- package/dist/deepccc-agent/src/index.js +571 -0
- package/dist/deepccc-agent/src/permissions.js +170 -0
- package/dist/deepccc-agent/src/privacy.js +124 -0
- package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
- package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
- package/dist/deepccc-agent/src/progress/reducer.js +102 -0
- package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
- package/dist/deepccc-agent/src/progress/view.js +30 -0
- package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
- package/dist/deepccc-agent/src/session-search.js +276 -0
- package/dist/deepccc-agent/src/session-select.js +23 -0
- package/dist/deepccc-agent/src/sigint.js +26 -0
- package/dist/deepccc-agent/src/skills.js +178 -0
- package/dist/deepccc-agent/src/web-tools.js +246 -0
- package/dist/src/adapters/adapter-interface.js +19 -0
- package/dist/src/adapters/ccc-adapter.js +112 -0
- package/dist/src/adapters/claude-adapter.js +497 -0
- package/dist/src/adapters/claude-session-meta-store.js +92 -0
- package/dist/src/adapters/codex-adapter.js +279 -0
- package/dist/src/adapters/codex-session-meta-store.js +94 -0
- package/dist/src/adapters/cursor-adapter.js +491 -0
- package/dist/src/adapters/cursor-session-meta-store.js +116 -0
- package/dist/src/adapters/jsonl-stream.js +104 -0
- package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
- package/dist/src/adapters/raw-stream-log.js +106 -0
- package/dist/src/adapters/resource-monitor.js +113 -0
- package/dist/src/agent-activity.js +133 -0
- package/dist/src/agent-delegate-task-rpc.js +129 -0
- package/dist/src/agent-delegate-task.js +48 -0
- package/dist/src/agent-file-rpc.js +152 -0
- package/dist/src/agent-image-rpc.js +148 -0
- package/dist/src/agent-platform-routing.js +13 -0
- package/dist/src/agent-reload-config-rpc.js +23 -0
- package/dist/src/agent-rpc-body.js +87 -0
- package/dist/src/agent-stop-stuck.js +110 -0
- package/dist/src/card-action-routing.js +7 -0
- package/dist/src/card-plain-text.js +101 -0
- package/dist/src/cardkit.js +158 -0
- package/dist/src/cards.js +573 -0
- package/dist/src/chatgpt-subscription-rpc.js +18 -0
- package/dist/src/chatgpt-subscription.js +199 -0
- package/dist/src/chrome-devtools-guard.js +238 -0
- package/dist/src/claude-sdk-installer.js +249 -0
- package/dist/src/codex-reset-actions.js +143 -0
- package/dist/src/config-utils.js +149 -0
- package/dist/src/config.js +804 -0
- package/dist/src/cursor-usage.js +77 -0
- package/dist/src/exit-banner.js +28 -0
- package/dist/src/feishu-api.js +1404 -0
- package/dist/src/feishu-message-ingress.js +137 -0
- package/dist/src/feishu-platform.js +97 -0
- package/dist/src/format-message.js +252 -0
- package/dist/src/git-command.js +155 -0
- package/dist/src/im-skills.js +121 -0
- package/dist/src/index.js +833 -0
- package/dist/src/litellm-proxy.js +300 -0
- package/dist/src/orchestrator.js +2078 -0
- package/dist/src/package-root.js +26 -0
- package/dist/src/platform-adapter.js +7 -0
- package/dist/src/platform-startup.js +6 -0
- package/dist/src/privacy.js +100 -0
- package/dist/src/progress/reducer.js +102 -0
- package/dist/src/progress/terminal-renderer.js +264 -0
- package/dist/src/progress/view.js +30 -0
- package/dist/src/response-stall.js +14 -0
- package/dist/src/runtime-entry.js +13 -0
- package/dist/src/runtime-reload.js +19 -0
- package/dist/src/session-chat-binding.js +183 -0
- package/dist/src/session-name.js +7 -0
- package/dist/src/session.js +2144 -0
- package/dist/src/shared-prefix.js +16 -0
- package/dist/src/shared.js +493 -0
- package/dist/src/sim-agent.js +105 -0
- package/dist/src/sim-platform.js +142 -0
- package/dist/src/sim-store.js +231 -0
- package/dist/src/simplify.js +99 -0
- package/dist/src/startup-lifecycle.js +209 -0
- package/dist/src/stream-state.js +141 -0
- package/dist/src/terminal-error.js +100 -0
- package/dist/src/trace.js +50 -0
- package/dist/src/turn-cards.js +92 -0
- package/dist/src/update-command-guard.js +114 -0
- package/{src/web-ui.ts → dist/src/web-ui.js} +749 -823
- package/dist/src/wechat-platform.js +545 -0
- package/package.json +7 -5
- package/deepccc-agent/LICENSE +0 -201
- package/deepccc-agent/bin/deepccc.mjs +0 -26
- package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
- package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
- package/deepccc-agent/package-lock.json +0 -2027
- package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -852
- package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
- package/deepccc-agent/src/__tests__/config.test.ts +0 -34
- package/deepccc-agent/src/__tests__/context.test.ts +0 -341
- package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
- package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
- package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
- package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
- package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
- package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
- package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
- package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
- package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
- package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
- package/deepccc-agent/src/cli.ts +0 -682
- package/deepccc-agent/src/config.ts +0 -101
- package/deepccc-agent/src/context.ts +0 -465
- package/deepccc-agent/src/file-log.ts +0 -38
- package/deepccc-agent/src/file-tools.ts +0 -1493
- package/deepccc-agent/src/index.ts +0 -676
- package/deepccc-agent/src/permissions.ts +0 -226
- package/deepccc-agent/src/privacy.ts +0 -141
- package/deepccc-agent/src/proc-tree-kill.ts +0 -61
- package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
- package/deepccc-agent/src/progress/reducer.ts +0 -113
- package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
- package/deepccc-agent/src/progress/view.ts +0 -77
- package/deepccc-agent/src/raw-stream-log.ts +0 -124
- package/deepccc-agent/src/session-search.ts +0 -370
- package/deepccc-agent/src/session-select.ts +0 -48
- package/deepccc-agent/src/sigint.ts +0 -50
- package/deepccc-agent/src/skills.ts +0 -205
- package/deepccc-agent/src/web-tools.ts +0 -313
- package/deepccc-agent/tsconfig.build.json +0 -13
- package/deepccc-agent/tsconfig.json +0 -13
- package/deepccc-agent/vitest.config.ts +0 -7
- package/src/__tests__/adapter-interface.test.ts +0 -152
- package/src/__tests__/agent-activity.test.ts +0 -86
- package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
- package/src/__tests__/agent-image-rpc.test.ts +0 -34
- package/src/__tests__/agent-platform-routing.test.ts +0 -26
- package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
- package/src/__tests__/agent-rpc-body.test.ts +0 -42
- package/src/__tests__/builtin-chat-session.test.ts +0 -532
- package/src/__tests__/builtin-cli-json.test.ts +0 -39
- package/src/__tests__/builtin-config.test.ts +0 -26
- package/src/__tests__/builtin-context.test.ts +0 -319
- package/src/__tests__/builtin-file-tools.test.ts +0 -240
- package/src/__tests__/builtin-permissions.test.ts +0 -219
- package/src/__tests__/builtin-session-search.test.ts +0 -262
- package/src/__tests__/builtin-session-select.test.ts +0 -116
- package/src/__tests__/builtin-sigint.test.ts +0 -56
- package/src/__tests__/builtin-skills.test.ts +0 -284
- package/src/__tests__/builtin-web-tools.test.ts +0 -220
- package/src/__tests__/card-action-routing.test.ts +0 -18
- package/src/__tests__/card-plain-text.test.ts +0 -45
- package/src/__tests__/cardkit.test.ts +0 -60
- package/src/__tests__/cards.test.ts +0 -607
- package/src/__tests__/ccc-adapter.test.ts +0 -194
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
- package/src/__tests__/chatgpt-subscription.test.ts +0 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
- package/src/__tests__/claude-adapter.test.ts +0 -614
- package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
- package/src/__tests__/claude-sdk-installer.test.ts +0 -285
- package/src/__tests__/codex-adapter.test.ts +0 -331
- package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
- package/src/__tests__/codex-reset-actions.test.ts +0 -146
- package/src/__tests__/config-reload.test.ts +0 -284
- package/src/__tests__/config-sample.test.ts +0 -97
- package/src/__tests__/config-utils.test.ts +0 -40
- package/src/__tests__/config.test.ts +0 -395
- package/src/__tests__/crash-logging.test.ts +0 -360
- package/src/__tests__/cursor-adapter.test.ts +0 -890
- package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
- package/src/__tests__/feishu-api.test.ts +0 -60
- package/src/__tests__/feishu-avatar.test.ts +0 -504
- package/src/__tests__/feishu-message-ingress.test.ts +0 -138
- package/src/__tests__/feishu-platform.test.ts +0 -75
- package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
- package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
- package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
- package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
- package/src/__tests__/format-message.test.ts +0 -316
- package/src/__tests__/git-command.test.ts +0 -288
- package/src/__tests__/im-skills.test.ts +0 -125
- package/src/__tests__/jsonl-stream.test.ts +0 -79
- package/src/__tests__/orchestrator.test.ts +0 -1268
- package/src/__tests__/package-files.test.ts +0 -24
- package/src/__tests__/platform-startup.test.ts +0 -19
- package/src/__tests__/privacy.test.ts +0 -198
- package/src/__tests__/proc-tree-kill.test.ts +0 -108
- package/src/__tests__/progress-reducer.test.ts +0 -121
- package/src/__tests__/raw-stream-log.test.ts +0 -106
- package/src/__tests__/response-stall.test.ts +0 -49
- package/src/__tests__/restart.test.ts +0 -232
- package/src/__tests__/session-ccc-config.test.ts +0 -66
- package/src/__tests__/session.test.ts +0 -3004
- package/src/__tests__/shared-prefix.test.ts +0 -36
- package/src/__tests__/sim-agent.test.ts +0 -174
- package/src/__tests__/sim-platform.test.ts +0 -93
- package/src/__tests__/sim-store.test.ts +0 -214
- package/src/__tests__/simplify.test.ts +0 -283
- package/src/__tests__/startup-lifecycle.test.ts +0 -231
- package/src/__tests__/stop-session.test.ts +0 -162
- package/src/__tests__/stream-state.test.ts +0 -164
- package/src/__tests__/terminal-error.test.ts +0 -54
- package/src/__tests__/terminal-renderer.test.ts +0 -247
- package/src/__tests__/update-command-guard.test.ts +0 -144
- package/src/__tests__/web-ui.test.ts +0 -438
- package/src/__tests__/wechat-platform.test.ts +0 -111
- package/src/adapters/adapter-interface.ts +0 -217
- package/src/adapters/ccc-adapter.ts +0 -150
- package/src/adapters/claude-adapter.ts +0 -673
- package/src/adapters/claude-session-meta-store.ts +0 -120
- package/src/adapters/codex-adapter.ts +0 -426
- package/src/adapters/codex-session-meta-store.ts +0 -131
- package/src/adapters/cursor-adapter.ts +0 -681
- package/src/adapters/cursor-session-meta-store.ts +0 -154
- package/src/adapters/jsonl-stream.ts +0 -157
- package/src/adapters/raw-stream-log.ts +0 -124
- package/src/adapters/resource-monitor.ts +0 -141
- package/src/agent-activity.ts +0 -175
- package/src/agent-delegate-task-rpc.ts +0 -153
- package/src/agent-delegate-task.ts +0 -91
- package/src/agent-file-rpc.ts +0 -172
- package/src/agent-image-rpc.ts +0 -168
- package/src/agent-platform-routing.ts +0 -28
- package/src/agent-reload-config-rpc.ts +0 -34
- package/src/agent-rpc-body.ts +0 -92
- package/src/agent-stop-stuck.ts +0 -129
- package/src/card-action-routing.ts +0 -14
- package/src/card-plain-text.ts +0 -108
- package/src/cardkit.ts +0 -179
- package/src/cards.ts +0 -684
- package/src/chatgpt-subscription-rpc.ts +0 -27
- package/src/chatgpt-subscription.ts +0 -299
- package/src/chrome-devtools-guard.ts +0 -318
- package/src/claude-sdk-installer.ts +0 -324
- package/src/codex-reset-actions.ts +0 -184
- package/src/config-utils.ts +0 -211
- package/src/config.ts +0 -1063
- package/src/cursor-usage.ts +0 -128
- package/src/exit-banner.ts +0 -33
- package/src/feishu-api.ts +0 -1616
- package/src/feishu-message-ingress.ts +0 -195
- package/src/feishu-platform.ts +0 -159
- package/src/format-message.ts +0 -293
- package/src/git-command.ts +0 -202
- package/src/im-skills.ts +0 -149
- package/src/index.ts +0 -1089
- package/src/litellm-proxy.ts +0 -374
- package/src/orchestrator.ts +0 -2543
- package/src/platform-adapter.ts +0 -70
- package/src/platform-startup.ts +0 -16
- package/src/privacy.ts +0 -118
- package/src/progress/reducer.ts +0 -113
- package/src/progress/terminal-renderer.ts +0 -294
- package/src/progress/view.ts +0 -77
- package/src/response-stall.ts +0 -28
- package/src/runtime-reload.ts +0 -34
- package/src/session-chat-binding.ts +0 -292
- package/src/session-name.ts +0 -8
- package/src/session.ts +0 -2659
- package/src/shared-prefix.ts +0 -29
- package/src/shared.ts +0 -552
- package/src/sim-agent.ts +0 -167
- package/src/sim-platform.ts +0 -177
- package/src/sim-store.ts +0 -317
- package/src/simplify.ts +0 -120
- package/src/startup-lifecycle.ts +0 -250
- package/src/stream-state.ts +0 -177
- package/src/terminal-error.ts +0 -129
- package/src/trace.ts +0 -51
- package/src/turn-cards.ts +0 -118
- package/src/update-command-guard.ts +0 -165
- package/src/wechat-platform.ts +0 -680
package/src/session.ts
DELETED
|
@@ -1,2659 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
CLAUDE_API_KEY,
|
|
6
|
-
CLAUDE_BASE_URL,
|
|
7
|
-
CLAUDE_MAX_TURN,
|
|
8
|
-
CLAUDE_MODEL,
|
|
9
|
-
CLAUDE_SUBAGENT_MODEL,
|
|
10
|
-
CHATCCC_PORT,
|
|
11
|
-
PROJECT_ROOT,
|
|
12
|
-
SESSIONS_FILE,
|
|
13
|
-
USER_DATA_DIR,
|
|
14
|
-
addRecentDir,
|
|
15
|
-
anthropicConfigDisplay,
|
|
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 { 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";
|
|
32
|
-
import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
|
|
33
|
-
import { logTrace } from "./trace.ts";
|
|
34
|
-
import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
|
|
35
|
-
import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
36
|
-
import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
|
|
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
|
-
classifyTerminalError,
|
|
48
|
-
formatTerminalErrorNotice,
|
|
49
|
-
formatTerminalErrorReason,
|
|
50
|
-
type TerminalErrorInfo,
|
|
51
|
-
} from "./terminal-error.ts";
|
|
52
|
-
import {
|
|
53
|
-
MAX_PROCESSED,
|
|
54
|
-
clearFeishuMessageLedgerMemory,
|
|
55
|
-
processedMessages,
|
|
56
|
-
} from "./feishu-message-ingress.ts";
|
|
57
|
-
|
|
58
|
-
export { MAX_PROCESSED, processedMessages };
|
|
59
|
-
|
|
60
|
-
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
61
|
-
function compressWechatDisplayText(text: string): string {
|
|
62
|
-
const lines = text.split("\n");
|
|
63
|
-
if (lines.length <= 10) return text;
|
|
64
|
-
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
65
|
-
}
|
|
66
|
-
import {
|
|
67
|
-
readStreamState,
|
|
68
|
-
writeStreamState,
|
|
69
|
-
createEmptyStreamState,
|
|
70
|
-
fixStaleStreamStates,
|
|
71
|
-
isFinalReplySentForTurn,
|
|
72
|
-
markFinalReplySent,
|
|
73
|
-
} from "./stream-state.ts";
|
|
74
|
-
import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
|
|
75
|
-
import {
|
|
76
|
-
bindChatToSession,
|
|
77
|
-
unbindChatFromSession,
|
|
78
|
-
getChatsForSession,
|
|
79
|
-
isSessionRunning,
|
|
80
|
-
activePrompts,
|
|
81
|
-
displayCards,
|
|
82
|
-
unifiedDisplayLoopHandle,
|
|
83
|
-
setUnifiedDisplayLoopHandle,
|
|
84
|
-
rebuildSessionChatsFromRegistry,
|
|
85
|
-
recordLastActiveChat,
|
|
86
|
-
getLastActiveChat,
|
|
87
|
-
pickDisplayChat,
|
|
88
|
-
dequeueMessage,
|
|
89
|
-
consumeQueuedMessage,
|
|
90
|
-
cancelQueuedMessage,
|
|
91
|
-
setQueuePreservedChat,
|
|
92
|
-
consumeQueuePreservedChat,
|
|
93
|
-
markSessionFinalizing,
|
|
94
|
-
clearSessionFinalizing,
|
|
95
|
-
reserveAutoRecovery,
|
|
96
|
-
consumeAutoRecoveryReservation,
|
|
97
|
-
cancelAutoRecoveryReservation,
|
|
98
|
-
hasAutoRecoveryReservation,
|
|
99
|
-
} from "./session-chat-binding.ts";
|
|
100
|
-
|
|
101
|
-
async function sendFinalReplyTextOnce(
|
|
102
|
-
platform: PlatformAdapter,
|
|
103
|
-
chatId: string,
|
|
104
|
-
sessionId: string,
|
|
105
|
-
turnCount: number,
|
|
106
|
-
text: string,
|
|
107
|
-
): Promise<boolean> {
|
|
108
|
-
const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
|
|
109
|
-
if (sent) await markFinalReplySent(sessionId, turnCount);
|
|
110
|
-
return sent;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function createVisibleProgressCard(
|
|
114
|
-
platform: PlatformAdapter,
|
|
115
|
-
chatId: string,
|
|
116
|
-
sessionId: string,
|
|
117
|
-
turnCount: number,
|
|
118
|
-
notifyFailureText?: string,
|
|
119
|
-
headerTitle = "正在启动 Agent · 0秒",
|
|
120
|
-
): Promise<string | null> {
|
|
121
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
122
|
-
let cardId: string | null = null;
|
|
123
|
-
try {
|
|
124
|
-
cardId = await platform.cardCreate(
|
|
125
|
-
buildProgressCard(progressView({ text: "等待 Agent 输出...", showStop: true, headerTitle })),
|
|
126
|
-
);
|
|
127
|
-
if (!cardId) throw new Error("empty card id");
|
|
128
|
-
await platform.cardSend(chatId, cardId);
|
|
129
|
-
await addCardToTurn(sessionId, turnCount, cardId);
|
|
130
|
-
return cardId;
|
|
131
|
-
} catch (err) {
|
|
132
|
-
console.error(
|
|
133
|
-
`[${ts()}] [DISPLAY] progress card send attempt ${attempt} failed: chatId=${chatId} cardId=${cardId || "(none)"} ${(err as Error).message}`,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (notifyFailureText) {
|
|
139
|
-
await platform.sendText(chatId, notifyFailureText).catch(() => {});
|
|
140
|
-
}
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Shared state (imported by index.ts)
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
/** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
|
|
149
|
-
export const lastMsgTimestamps = new Map<string, number>();
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// 平台引用 —— session 模块通过此引用访问 IM 平台操作,
|
|
153
|
-
// 避免 import feishu-platform.ts 造成的耦合。
|
|
154
|
-
// 由 index.ts 在启动时调用 setSessionPlatform 注入。
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
let platformRef: PlatformAdapter | null = null;
|
|
158
|
-
const chatPlatformMap = new Map<string, PlatformAdapter>();
|
|
159
|
-
|
|
160
|
-
/** 注入当前 IM 平台适配器,供 session 模块使用 */
|
|
161
|
-
export function setSessionPlatform(platform: PlatformAdapter): void {
|
|
162
|
-
platformRef = platform;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function recordChatPlatform(chatId: string, platform: PlatformAdapter): void {
|
|
166
|
-
chatPlatformMap.set(chatId, platform);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function forgetChatPlatform(chatId: string): void {
|
|
170
|
-
chatPlatformMap.delete(chatId);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function platformForChat(chatId: string): PlatformAdapter | null {
|
|
174
|
-
return chatPlatformMap.get(chatId) ?? platformRef;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
178
|
-
const DEFAULT_AVATAR_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
179
|
-
const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
|
|
180
|
-
const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
|
|
181
|
-
const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
|
|
182
|
-
export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
|
|
183
|
-
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
184
|
-
`检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
|
|
185
|
-
export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
|
|
186
|
-
"⚠️ 自动续跑仍连续 3 分钟没有生成新回复,本次不再自动继续。";
|
|
187
|
-
const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
|
|
188
|
-
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
189
|
-
let avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
|
|
190
|
-
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
191
|
-
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
192
|
-
let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
|
|
193
|
-
let isProcessAliveImpl = (pid: number): boolean => {
|
|
194
|
-
try {
|
|
195
|
-
process.kill(pid, 0);
|
|
196
|
-
return true;
|
|
197
|
-
} catch {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
export function _setProcessAliveForTest(impl: (pid: number) => boolean): void {
|
|
203
|
-
isProcessAliveImpl = impl;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function _resetProcessAliveForTest(): void {
|
|
207
|
-
isProcessAliveImpl = (pid: number): boolean => {
|
|
208
|
-
try {
|
|
209
|
-
process.kill(pid, 0);
|
|
210
|
-
return true;
|
|
211
|
-
} catch {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function _setProcessMonitorIntervalForTest(ms: number): void {
|
|
218
|
-
processMonitorIntervalMs = ms;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function _resetProcessMonitorIntervalForTest(): void {
|
|
222
|
-
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export function _setAvatarRefreshIntervalForTest(ms: number): void {
|
|
226
|
-
avatarRefreshIntervalMs = ms;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function _resetAvatarRefreshIntervalForTest(): void {
|
|
230
|
-
avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function _setResponseStallTimeoutForTest(ms: number): void {
|
|
234
|
-
responseStallTimeoutMs = ms;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function _resetResponseStallTimeoutForTest(): void {
|
|
238
|
-
responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function _setResponseStallCheckIntervalForTest(ms: number): void {
|
|
242
|
-
responseStallCheckIntervalMs = ms;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function _resetResponseStallCheckIntervalForTest(): void {
|
|
246
|
-
responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
|
|
250
|
-
finalResponseCloseTimeoutMs = ms;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export function _resetFinalResponseCloseTimeoutForTest(): void {
|
|
254
|
-
finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function clearPromptProcessMonitor(sessionId: string): void {
|
|
258
|
-
const prompt = activePrompts.get(sessionId);
|
|
259
|
-
if (!prompt?.processMonitor) return;
|
|
260
|
-
clearInterval(prompt.processMonitor);
|
|
261
|
-
prompt.processMonitor = undefined;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function clearPromptResponseStallMonitor(sessionId: string): void {
|
|
265
|
-
const prompt = activePrompts.get(sessionId);
|
|
266
|
-
if (!prompt?.responseStallMonitor) return;
|
|
267
|
-
clearInterval(prompt.responseStallMonitor);
|
|
268
|
-
prompt.responseStallMonitor = undefined;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function clearPromptAvatarRefreshTimer(sessionId: string): void {
|
|
272
|
-
const prompt = activePrompts.get(sessionId);
|
|
273
|
-
if (!prompt?.avatarRefreshTimer) return;
|
|
274
|
-
clearInterval(prompt.avatarRefreshTimer);
|
|
275
|
-
prompt.avatarRefreshTimer = undefined;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function clearPromptFinalResponseCloseTimer(sessionId: string): void {
|
|
279
|
-
const prompt = activePrompts.get(sessionId);
|
|
280
|
-
if (!prompt?.finalResponseCloseTimer) return;
|
|
281
|
-
clearTimeout(prompt.finalResponseCloseTimer);
|
|
282
|
-
prompt.finalResponseCloseTimer = undefined;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
|
|
287
|
-
* 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
|
|
288
|
-
* 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
|
|
289
|
-
*/
|
|
290
|
-
function scheduleFinalResponseCloseGuard(
|
|
291
|
-
sessionId: string,
|
|
292
|
-
runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
|
|
293
|
-
): void {
|
|
294
|
-
if (runningPrompt.finalResponseCloseTimer) return;
|
|
295
|
-
|
|
296
|
-
const timeoutMs = finalResponseCloseTimeoutMs;
|
|
297
|
-
const handle = setTimeout(() => {
|
|
298
|
-
const current = activePrompts.get(sessionId);
|
|
299
|
-
if (
|
|
300
|
-
!current
|
|
301
|
-
|| current !== runningPrompt
|
|
302
|
-
|| !current.finalResponseObserved
|
|
303
|
-
|| current.stopped
|
|
304
|
-
|| current.abnormalExit
|
|
305
|
-
|| current.resourceStuck
|
|
306
|
-
|| current.autoEnded
|
|
307
|
-
) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
current.finalResponseCloseTimer = undefined;
|
|
312
|
-
clearPromptProcessMonitor(sessionId);
|
|
313
|
-
clearPromptResponseStallMonitor(sessionId);
|
|
314
|
-
try {
|
|
315
|
-
current.closeSession?.();
|
|
316
|
-
} catch (err) {
|
|
317
|
-
console.warn(
|
|
318
|
-
`[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
current.controller.abort();
|
|
322
|
-
void killProcessTree(current.processPid);
|
|
323
|
-
console.warn(
|
|
324
|
-
`[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
|
|
325
|
-
);
|
|
326
|
-
}, timeoutMs);
|
|
327
|
-
handle.unref?.();
|
|
328
|
-
runningPrompt.finalResponseCloseTimer = handle;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function formatTerminalHeader(
|
|
332
|
-
status: "running" | "done" | "stopped" | "error" | "auto_ended",
|
|
333
|
-
terminalError?: TerminalErrorInfo,
|
|
334
|
-
): {
|
|
335
|
-
title: string;
|
|
336
|
-
template?: string;
|
|
337
|
-
} {
|
|
338
|
-
if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
|
|
339
|
-
if (status === "stopped") return { title: "已停止", template: "red" };
|
|
340
|
-
if (status === "error") {
|
|
341
|
-
return { title: terminalError ? `异常结束 · ${terminalError.title}` : "异常结束", template: "red" };
|
|
342
|
-
}
|
|
343
|
-
return { title: "完成" };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "auto_ended"): "done" | "stopped" {
|
|
347
|
-
return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function formatAutoEndedReply(finalReply: string): string {
|
|
351
|
-
const reason = "⚠️ 已自动结束:生成回复阶段连续 3 分钟没有字符变化。";
|
|
352
|
-
return finalReply
|
|
353
|
-
? `${reason}以下回复可能不完整。\n\n${finalReply}`
|
|
354
|
-
: `${reason}本轮没有可发送的回复内容。`;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* 只监控明确的回复生成阶段。启动、压缩、思考、工具调用和搜索都有各自的
|
|
359
|
-
* 状态或资源保护,不能把它们消耗的时间算入回复停滞窗口。
|
|
360
|
-
*/
|
|
361
|
-
function monitorsOutputProgress(kind: AgentActivityKind): boolean {
|
|
362
|
-
return kind === "responding";
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function formatTerminalReply(
|
|
366
|
-
status: "running" | "done" | "stopped" | "error" | "auto_ended",
|
|
367
|
-
finalReply: string,
|
|
368
|
-
terminalError?: TerminalErrorInfo,
|
|
369
|
-
): string | null {
|
|
370
|
-
if (status === "auto_ended") return formatAutoEndedReply(finalReply);
|
|
371
|
-
if (status === "error") {
|
|
372
|
-
const error = terminalError ?? {
|
|
373
|
-
kind: "unknown" as const,
|
|
374
|
-
title: "原因未记录",
|
|
375
|
-
message: "当前状态中没有可用的错误详情,请查看运行日志。",
|
|
376
|
-
occurredAt: Date.now(),
|
|
377
|
-
};
|
|
378
|
-
return formatTerminalErrorNotice(error, finalReply);
|
|
379
|
-
}
|
|
380
|
-
return finalReply || null;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function formatTerminalCardContent(state: {
|
|
384
|
-
status: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
385
|
-
accumulatedContent: string;
|
|
386
|
-
finalReply: string;
|
|
387
|
-
terminalError?: TerminalErrorInfo;
|
|
388
|
-
}): string {
|
|
389
|
-
const content = state.accumulatedContent + state.finalReply;
|
|
390
|
-
if (state.status !== "error") return content;
|
|
391
|
-
|
|
392
|
-
const error = state.terminalError ?? {
|
|
393
|
-
kind: "unknown" as const,
|
|
394
|
-
title: "原因未记录",
|
|
395
|
-
message: "当前状态中没有可用的错误详情,请查看运行日志。",
|
|
396
|
-
occurredAt: Date.now(),
|
|
397
|
-
};
|
|
398
|
-
const reason = formatTerminalErrorReason(error);
|
|
399
|
-
return content.trim()
|
|
400
|
-
? `${content}\n\n${reason}\n以上内容可能不完整。`
|
|
401
|
-
: reason;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function isCardKitSequenceConflict(err: unknown): boolean {
|
|
405
|
-
return err instanceof Error && err.message.includes("300317");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
|
|
409
|
-
const prompt = activePrompts.get(sessionId);
|
|
410
|
-
if (!prompt) return;
|
|
411
|
-
prompt.processPid = info.pid;
|
|
412
|
-
clearPromptProcessMonitor(sessionId);
|
|
413
|
-
|
|
414
|
-
const check = async () => {
|
|
415
|
-
const current = activePrompts.get(sessionId);
|
|
416
|
-
if (!current || current !== prompt) {
|
|
417
|
-
clearPromptProcessMonitor(sessionId);
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded) return;
|
|
421
|
-
if (isProcessAliveImpl(info.pid)) return;
|
|
422
|
-
|
|
423
|
-
current.abnormalExit = true;
|
|
424
|
-
clearPromptProcessMonitor(sessionId);
|
|
425
|
-
|
|
426
|
-
const state = await readStreamState(sessionId);
|
|
427
|
-
if (state?.status === "running") {
|
|
428
|
-
await writeStreamState({
|
|
429
|
-
...state,
|
|
430
|
-
status: "error",
|
|
431
|
-
updatedAt: Date.now(),
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
436
|
-
const p = chatId ? platformForChat(chatId) : null;
|
|
437
|
-
if (chatId && p && !current.abnormalExitNotified) {
|
|
438
|
-
current.abnormalExitNotified = true;
|
|
439
|
-
await p.sendText(
|
|
440
|
-
chatId,
|
|
441
|
-
`⚠️ 进程异常结束:session ${sessionId} 对应的 CLI 进程 PID ${info.pid} 已不存在,已按完成处理。若回复不完整,请重新发送上一条指令。`,
|
|
442
|
-
).catch(() => {});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// 主动关闭 readline,让 runAgentSession 的 finally 落盘 error 终态并清理 activePrompts。
|
|
446
|
-
current.controller.abort();
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
const handle = setInterval(() => {
|
|
450
|
-
void check().catch((err) => {
|
|
451
|
-
console.warn(`[${ts()}] [PROCESS-MONITOR] check failed for ${sessionId}: ${(err as Error).message}`);
|
|
452
|
-
});
|
|
453
|
-
}, processMonitorIntervalMs);
|
|
454
|
-
handle.unref?.();
|
|
455
|
-
prompt.processMonitor = handle;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | null {
|
|
459
|
-
return platformForChat(chatId);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
export function getPlatformForChat(chatId: string): PlatformAdapter | null {
|
|
463
|
-
return platformForChat(chatId);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function imSkillNamesForPlatform(platform: PlatformAdapter): string[] {
|
|
467
|
-
if (platform.kind === "wechat") {
|
|
468
|
-
return ["wechat-image-skill", "wechat-file-skill", "wechat-video-skill"];
|
|
469
|
-
}
|
|
470
|
-
return ["feishu-skill"];
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
export let sessionGen = 0;
|
|
474
|
-
/** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
|
|
475
|
-
export const chatSessionMap = new Map<string, {
|
|
476
|
-
gen: number;
|
|
477
|
-
close: () => void;
|
|
478
|
-
cardId: string | null;
|
|
479
|
-
stopped: boolean;
|
|
480
|
-
accumulatedContent: string;
|
|
481
|
-
finalText: string;
|
|
482
|
-
spinnerTimer: ReturnType<typeof setInterval> | null;
|
|
483
|
-
msgTimestamp: number;
|
|
484
|
-
sequence: number;
|
|
485
|
-
cardBusy: boolean;
|
|
486
|
-
}>();
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* sessionInfoMap 记录每个 chatId 当前绑定的会话元数据。
|
|
490
|
-
* 同一 session 可被多个 chatId 共享;model/effort 不在其中(按 tool 动态解析)。
|
|
491
|
-
*/
|
|
492
|
-
export const sessionInfoMap = new Map<string, {
|
|
493
|
-
sessionId: string;
|
|
494
|
-
turnCount: number;
|
|
495
|
-
lastContextTokens: number;
|
|
496
|
-
startTime: number;
|
|
497
|
-
tool: string;
|
|
498
|
-
}>();
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* 清空所有进程内运行时状态。
|
|
502
|
-
*
|
|
503
|
-
* ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
|
|
504
|
-
* SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
|
|
505
|
-
* stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
|
|
506
|
-
* 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
|
|
507
|
-
* generator 继续跑、继续写 stream-state.json,但 display loop 已被
|
|
508
|
-
* stop,用户群里再也看不到任何更新;最终回复永远不发到群。
|
|
509
|
-
* 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
|
|
510
|
-
* `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
|
|
511
|
-
* 输出互相串扰、token 计费翻倍。
|
|
512
|
-
* 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
|
|
513
|
-
* 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
|
|
514
|
-
* 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
|
|
515
|
-
*
|
|
516
|
-
* 合法调用点:
|
|
517
|
-
* - 单元测试 setup(清测试间状态)
|
|
518
|
-
* - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
|
|
519
|
-
*
|
|
520
|
-
* SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
|
|
521
|
-
* chatId 映射,不动任何运行时状态。
|
|
522
|
-
*/
|
|
523
|
-
export function resetState(): void {
|
|
524
|
-
for (const entry of chatSessionMap.values()) {
|
|
525
|
-
if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
|
|
526
|
-
try { entry.close(); } catch { /* ignore */ }
|
|
527
|
-
}
|
|
528
|
-
chatSessionMap.clear();
|
|
529
|
-
sessionInfoMap.clear();
|
|
530
|
-
clearFeishuMessageLedgerMemory();
|
|
531
|
-
lastMsgTimestamps.clear();
|
|
532
|
-
chatPlatformMap.clear();
|
|
533
|
-
for (const prompt of activePrompts.values()) {
|
|
534
|
-
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
535
|
-
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
536
|
-
if (prompt.avatarRefreshTimer) clearInterval(prompt.avatarRefreshTimer);
|
|
537
|
-
if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
|
|
538
|
-
}
|
|
539
|
-
activePrompts.clear();
|
|
540
|
-
displayCards.clear();
|
|
541
|
-
sessionModelOverrides.clear();
|
|
542
|
-
sessionEffortOverrides.clear();
|
|
543
|
-
sessionFastModeOverrides.clear();
|
|
544
|
-
adapterCache.clear();
|
|
545
|
-
stopUnifiedDisplayLoop();
|
|
546
|
-
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
|
|
550
|
-
// 是 onReady/onReconnected 取代 resetState 的正确入口。
|
|
551
|
-
|
|
552
|
-
// ---------------------------------------------------------------------------
|
|
553
|
-
// Adapter: 按 tool + effectiveModel 创建并缓存
|
|
554
|
-
// ---------------------------------------------------------------------------
|
|
555
|
-
|
|
556
|
-
const adapterCache = new Map<string, ToolAdapter>();
|
|
557
|
-
|
|
558
|
-
// Per-session 模型覆盖(/model 命令设置,不持久化)
|
|
559
|
-
const sessionModelOverrides = new Map<string, string>();
|
|
560
|
-
const sessionEffortOverrides = new Map<string, string>();
|
|
561
|
-
const sessionFastModeOverrides = new Map<string, boolean>();
|
|
562
|
-
|
|
563
|
-
/** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
|
|
564
|
-
function getModelForSession(sessionId?: string): string {
|
|
565
|
-
if (sessionId) {
|
|
566
|
-
const override = sessionModelOverrides.get(sessionId);
|
|
567
|
-
if (override) return override;
|
|
568
|
-
}
|
|
569
|
-
return CLAUDE_MODEL;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
|
|
573
|
-
export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
|
|
574
|
-
if (sessionId) {
|
|
575
|
-
const override = sessionModelOverrides.get(sessionId);
|
|
576
|
-
if (override) return override;
|
|
577
|
-
}
|
|
578
|
-
if (tool === "cursor") return config.cursor.model;
|
|
579
|
-
if (tool === "codex") return config.codex.model;
|
|
580
|
-
if (tool === "ccc") return config.ccc.model;
|
|
581
|
-
return CLAUDE_MODEL;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export function getEffectiveEffortForTool(tool: string, sessionId?: string): string {
|
|
585
|
-
if (sessionId) {
|
|
586
|
-
const override = sessionEffortOverrides.get(sessionId);
|
|
587
|
-
if (override) return override;
|
|
588
|
-
}
|
|
589
|
-
if (tool === "claude" || tool === "codex" || tool === "ccc") {
|
|
590
|
-
return getDefaultEffortForTool(tool);
|
|
591
|
-
}
|
|
592
|
-
return "";
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
export function getEffectiveFastModeForTool(tool: string, sessionId?: string): boolean {
|
|
596
|
-
if (tool !== "codex") return false;
|
|
597
|
-
if (sessionId && sessionFastModeOverrides.has(sessionId)) {
|
|
598
|
-
return sessionFastModeOverrides.get(sessionId) === true;
|
|
599
|
-
}
|
|
600
|
-
return config.codex.fastMode;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function setSessionChatAvatar(
|
|
604
|
-
platform: PlatformAdapter,
|
|
605
|
-
chatId: string,
|
|
606
|
-
tool: string,
|
|
607
|
-
status: string,
|
|
608
|
-
sessionId: string,
|
|
609
|
-
): Promise<void> {
|
|
610
|
-
return getEffectiveFastModeForTool(tool, sessionId)
|
|
611
|
-
? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
|
|
612
|
-
: platform.setChatAvatar(chatId, tool, status);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async function refreshBusySessionAvatar(
|
|
616
|
-
sessionId: string,
|
|
617
|
-
tool: string,
|
|
618
|
-
fallbackPlatform: PlatformAdapter,
|
|
619
|
-
): Promise<void> {
|
|
620
|
-
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
621
|
-
if (!chatId) return;
|
|
622
|
-
const platform = platformForChat(chatId) ?? fallbackPlatform;
|
|
623
|
-
await setSessionChatAvatar(platform, chatId, tool, "busy", sessionId);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function startPromptAvatarRefresh(
|
|
627
|
-
sessionId: string,
|
|
628
|
-
tool: string,
|
|
629
|
-
fallbackPlatform: PlatformAdapter,
|
|
630
|
-
runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
|
|
631
|
-
): void {
|
|
632
|
-
let refreshInFlight = false;
|
|
633
|
-
const timer = setInterval(() => {
|
|
634
|
-
const current = activePrompts.get(sessionId);
|
|
635
|
-
if (!current || current !== runningPrompt) {
|
|
636
|
-
clearInterval(timer);
|
|
637
|
-
if (runningPrompt.avatarRefreshTimer === timer) {
|
|
638
|
-
runningPrompt.avatarRefreshTimer = undefined;
|
|
639
|
-
}
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
if (
|
|
643
|
-
refreshInFlight
|
|
644
|
-
|| current.stopped
|
|
645
|
-
|| current.abnormalExit
|
|
646
|
-
|| current.resourceStuck
|
|
647
|
-
|| current.autoEnded
|
|
648
|
-
|| current.finalResponseObserved
|
|
649
|
-
) {
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
refreshInFlight = true;
|
|
654
|
-
void refreshBusySessionAvatar(sessionId, tool, fallbackPlatform)
|
|
655
|
-
.catch((err) => {
|
|
656
|
-
console.warn(`[${ts()}] [AVATAR] Periodic refresh failed for ${sessionId}: ${(err as Error).message}`);
|
|
657
|
-
})
|
|
658
|
-
.finally(() => {
|
|
659
|
-
refreshInFlight = false;
|
|
660
|
-
});
|
|
661
|
-
}, avatarRefreshIntervalMs);
|
|
662
|
-
timer.unref?.();
|
|
663
|
-
runningPrompt.avatarRefreshTimer = timer;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/** 为指定 session 设置模型覆盖(/model <name>) */
|
|
667
|
-
export function setSessionModelOverride(sessionId: string, model: string): void {
|
|
668
|
-
sessionModelOverrides.set(sessionId, model);
|
|
669
|
-
adapterCache.clear();
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** 清除指定 session 的模型覆盖(/model clear) */
|
|
673
|
-
export function clearSessionModelOverride(sessionId: string): void {
|
|
674
|
-
sessionModelOverrides.delete(sessionId);
|
|
675
|
-
adapterCache.clear();
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
export function setSessionEffortOverride(sessionId: string, effort: string): void {
|
|
679
|
-
sessionEffortOverrides.set(sessionId, effort);
|
|
680
|
-
adapterCache.clear();
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
export function clearSessionEffortOverride(sessionId: string): void {
|
|
684
|
-
sessionEffortOverrides.delete(sessionId);
|
|
685
|
-
adapterCache.clear();
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
export function setSessionFastModeOverride(sessionId: string, fastMode: boolean): void {
|
|
689
|
-
sessionFastModeOverrides.set(sessionId, fastMode);
|
|
690
|
-
adapterCache.clear();
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
|
|
694
|
-
const effectiveModel = getEffectiveModelForTool(tool, sessionId);
|
|
695
|
-
const effectiveEffort = getEffectiveEffortForTool(tool, sessionId);
|
|
696
|
-
const effectiveFastMode = getEffectiveFastModeForTool(tool, sessionId);
|
|
697
|
-
const cacheKey = `${tool}:${effectiveModel || ""}:${effectiveEffort || ""}:${effectiveFastMode ? "fast" : "default"}`;
|
|
698
|
-
const cached = adapterCache.get(cacheKey);
|
|
699
|
-
if (cached) return cached;
|
|
700
|
-
|
|
701
|
-
let adapter: ToolAdapter;
|
|
702
|
-
if (tool === "cursor") {
|
|
703
|
-
adapter = createCursorAdapter({ model: effectiveModel || undefined });
|
|
704
|
-
} else if (tool === "codex") {
|
|
705
|
-
adapter = createCodexAdapter({
|
|
706
|
-
model: effectiveModel || undefined,
|
|
707
|
-
effort: effectiveEffort || undefined,
|
|
708
|
-
fastMode: effectiveFastMode,
|
|
709
|
-
});
|
|
710
|
-
} else if (tool === "ccc") {
|
|
711
|
-
adapter = createCccAdapter({
|
|
712
|
-
apiKey: config.ccc.DEEPSEEK_API_KEY,
|
|
713
|
-
baseURL: config.ccc.DEEPSEEK_BASE_URL,
|
|
714
|
-
model: effectiveModel || undefined,
|
|
715
|
-
effort: effectiveEffort || undefined,
|
|
716
|
-
// 留空("")不传 → ChatSession 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER)
|
|
717
|
-
...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
|
|
718
|
-
});
|
|
719
|
-
} else {
|
|
720
|
-
adapter = createClaudeAdapter({
|
|
721
|
-
model: effectiveModel,
|
|
722
|
-
subagentModel: CLAUDE_SUBAGENT_MODEL,
|
|
723
|
-
effort: effectiveEffort,
|
|
724
|
-
apiKey: CLAUDE_API_KEY,
|
|
725
|
-
baseUrl: CLAUDE_BASE_URL,
|
|
726
|
-
isEmpty: isAnthropicConfigEmpty,
|
|
727
|
-
maxTurn: CLAUDE_MAX_TURN,
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
adapterCache.set(cacheKey, adapter);
|
|
731
|
-
return adapter;
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ---------------------------------------------------------------------------
|
|
735
|
-
// Session tool persistence (state/sessions.json)
|
|
736
|
-
// ---------------------------------------------------------------------------
|
|
737
|
-
|
|
738
|
-
interface SessionToolRecord {
|
|
739
|
-
tool: string;
|
|
740
|
-
createdAt: number;
|
|
741
|
-
chatName?: string;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
let sessionToolsFile = SESSIONS_FILE;
|
|
745
|
-
|
|
746
|
-
async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
|
|
747
|
-
try {
|
|
748
|
-
const raw = await readFile(sessionToolsFile, "utf-8");
|
|
749
|
-
return JSON.parse(raw);
|
|
750
|
-
} catch {
|
|
751
|
-
return {};
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
async function saveSessionTools(data: Record<string, SessionToolRecord>): Promise<void> {
|
|
756
|
-
try {
|
|
757
|
-
await mkdir(dirname(sessionToolsFile), { recursive: true });
|
|
758
|
-
await writeFile(sessionToolsFile, JSON.stringify(data, null, 2), "utf-8");
|
|
759
|
-
} catch (err) {
|
|
760
|
-
console.error(`[${ts()}] Failed to save sessions.json: ${(err as Error).message}`);
|
|
761
|
-
fileLog.flush();
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
export async function saveSessionTool(sessionId: string, tool: string, chatName?: string): Promise<void> {
|
|
766
|
-
const data = await loadSessionTools();
|
|
767
|
-
const existing = data[sessionId];
|
|
768
|
-
const mergedChatName = chatName ?? existing?.chatName;
|
|
769
|
-
data[sessionId] = {
|
|
770
|
-
tool,
|
|
771
|
-
createdAt: existing?.createdAt ?? Date.now(),
|
|
772
|
-
...(mergedChatName ? { chatName: mergedChatName } : {}),
|
|
773
|
-
};
|
|
774
|
-
await saveSessionTools(data);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export async function getSessionTool(sessionId: string): Promise<string | null> {
|
|
778
|
-
const data = await loadSessionTools();
|
|
779
|
-
const record = data[sessionId];
|
|
780
|
-
return record?.tool ?? null;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
export function _setSessionToolsFileForTest(filePath: string): void {
|
|
784
|
-
sessionToolsFile = filePath;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
export function _resetSessionToolsFileForTest(): void {
|
|
788
|
-
sessionToolsFile = SESSIONS_FILE;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// ---------------------------------------------------------------------------
|
|
792
|
-
// Conversation session registry for /sessions
|
|
793
|
-
// ---------------------------------------------------------------------------
|
|
794
|
-
|
|
795
|
-
export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
|
|
796
|
-
let sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
797
|
-
|
|
798
|
-
export interface SessionRegistryUpdate {
|
|
799
|
-
chatId: string;
|
|
800
|
-
sessionId: string;
|
|
801
|
-
tool: string;
|
|
802
|
-
/** 会话容器类型;旧 registry 没有该字段,读取时必须兼容。 */
|
|
803
|
-
chatType?: string;
|
|
804
|
-
chatName?: string;
|
|
805
|
-
turnCount?: number;
|
|
806
|
-
lastContextTokens?: number;
|
|
807
|
-
startTime?: number;
|
|
808
|
-
updatedAt?: number;
|
|
809
|
-
running?: boolean;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
interface SessionRegistryRecord {
|
|
813
|
-
chatId: string;
|
|
814
|
-
sessionId: string;
|
|
815
|
-
tool: string;
|
|
816
|
-
chatType?: string;
|
|
817
|
-
chatName: string;
|
|
818
|
-
turnCount: number;
|
|
819
|
-
lastContextTokens: number;
|
|
820
|
-
startTime: number;
|
|
821
|
-
updatedAt: number;
|
|
822
|
-
running: boolean;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
type SessionRegistryData = Record<string, SessionRegistryRecord>;
|
|
826
|
-
|
|
827
|
-
async function loadSessionRegistry(): Promise<SessionRegistryData> {
|
|
828
|
-
try {
|
|
829
|
-
const raw = await readFile(sessionRegistryFile, "utf-8");
|
|
830
|
-
const parsed = JSON.parse(raw) as SessionRegistryData;
|
|
831
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
832
|
-
} catch {
|
|
833
|
-
return {};
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
/** 供 session-chat-binding.ts 重建映射 */
|
|
838
|
-
export async function loadSessionRegistryForBinding(): Promise<SessionRegistryData> {
|
|
839
|
-
return loadSessionRegistry();
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* 从持久化的 registry 重建 sessionId → chatId 映射。
|
|
844
|
-
*
|
|
845
|
-
* 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
|
|
846
|
-
* - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
|
|
847
|
-
* 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
|
|
848
|
-
* 导致同一 sessionId 双开 generator
|
|
849
|
-
* - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
|
|
850
|
-
* - **不动** displayCards:正在跑的 prompt 还需要它们继续推卡片
|
|
851
|
-
* - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
|
|
852
|
-
* 去重 set 还在才能避免同一 prompt 跑两遍
|
|
853
|
-
*
|
|
854
|
-
* 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
|
|
855
|
-
* 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
|
|
856
|
-
*/
|
|
857
|
-
export async function rebuildBindingsFromRegistry(): Promise<void> {
|
|
858
|
-
const registry = await loadSessionRegistry();
|
|
859
|
-
rebuildSessionChatsFromRegistry(registry);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
863
|
-
try {
|
|
864
|
-
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
865
|
-
await writeFile(sessionRegistryFile, JSON.stringify(data, null, 2), "utf-8");
|
|
866
|
-
} catch (err) {
|
|
867
|
-
console.error(`[${ts()}] Failed to save session-registry.json: ${(err as Error).message}`);
|
|
868
|
-
fileLog.flush();
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
export async function recordSessionRegistry(update: SessionRegistryUpdate): Promise<void> {
|
|
873
|
-
const data = await loadSessionRegistry();
|
|
874
|
-
const existing = data[update.chatId];
|
|
875
|
-
const now = update.updatedAt ?? Date.now();
|
|
876
|
-
|
|
877
|
-
data[update.chatId] = {
|
|
878
|
-
chatId: update.chatId,
|
|
879
|
-
sessionId: update.sessionId,
|
|
880
|
-
tool: update.tool,
|
|
881
|
-
chatType: update.chatType ?? existing?.chatType,
|
|
882
|
-
chatName: update.chatName ?? existing?.chatName ?? "",
|
|
883
|
-
turnCount: update.turnCount ?? existing?.turnCount ?? 0,
|
|
884
|
-
lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
|
|
885
|
-
startTime: update.startTime ?? existing?.startTime ?? now,
|
|
886
|
-
updatedAt: now,
|
|
887
|
-
running: update.running ?? existing?.running ?? false,
|
|
888
|
-
};
|
|
889
|
-
|
|
890
|
-
await saveSessionRegistry(data);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
export async function removeSessionRegistryRecord(chatId: string): Promise<void> {
|
|
894
|
-
const data = await loadSessionRegistry();
|
|
895
|
-
delete data[chatId];
|
|
896
|
-
await saveSessionRegistry(data);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
export function _setSessionRegistryFileForTest(filePath: string): void {
|
|
900
|
-
sessionRegistryFile = filePath;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
export function _resetSessionRegistryFileForTest(): void {
|
|
904
|
-
sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// ---------------------------------------------------------------------------
|
|
908
|
-
// accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
|
|
909
|
-
// ---------------------------------------------------------------------------
|
|
910
|
-
|
|
911
|
-
// 注意:以下字段命名含 "final",但实际语义是"本轮所有文本的累积",并非仅
|
|
912
|
-
// "最后一段回复"。历史原因得名——当时以为 finalReply 只包含最后一轮输出,实则
|
|
913
|
-
// 所有 text block(工具调用前、调用间、调用后)都累加在这里。
|
|
914
|
-
// accumulatedContent + finalReply 拼接后才是完整流式输出的全部内容。
|
|
915
|
-
export interface AccumulatorState {
|
|
916
|
-
accumulatedContent: string;
|
|
917
|
-
/** 本轮所有 text block 的累加(流式文本 delta),包括工具调用前后的全部文本 */
|
|
918
|
-
finalText: string;
|
|
919
|
-
/**
|
|
920
|
-
* 适配器明确给出的"完整最终文本"(覆盖语义)。
|
|
921
|
-
* 仅 Cursor `--stream-partial-output` 模式末尾的 final assistant 消息会写入;
|
|
922
|
-
* 用于配合 pickFinalReply 在 partial 累加 vs final 完整文本之间挑选最终回复,
|
|
923
|
-
* 避免最终消息出现两段重复内容。
|
|
924
|
-
*/
|
|
925
|
-
finalCompleteText: string;
|
|
926
|
-
chunkCount: number;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* 在 partial 累加(finalText)与适配器给出的"完整最终文本"(finalCompleteText)
|
|
931
|
-
* 之间挑选最终回复:
|
|
932
|
-
* - finalCompleteText 非空时永远优先(来自 cursor result.result 等权威源)
|
|
933
|
-
* - 否则回退到 finalText(partial 累加)
|
|
934
|
-
*
|
|
935
|
-
* 不做长度比较:cursor 在工具调用前会发 buffered flush(重复快照),
|
|
936
|
-
* 若按当前 adapter 误把 buffered flush 当 delta 累加,partial 累加可能"虚高",
|
|
937
|
-
* 此时取更长会选错;权威源(result.result)才是正解。
|
|
938
|
-
*/
|
|
939
|
-
export function pickFinalReply(state: AccumulatorState): string {
|
|
940
|
-
return state.finalCompleteText || state.finalText;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
export function accumulateBlockContent(
|
|
944
|
-
block: UnifiedBlock,
|
|
945
|
-
state: AccumulatorState,
|
|
946
|
-
toolCallMap?: Map<string, { name: string; input: unknown }>,
|
|
947
|
-
): void {
|
|
948
|
-
switch (block.type) {
|
|
949
|
-
case "thinking":
|
|
950
|
-
state.chunkCount++;
|
|
951
|
-
// 用引用块标记思考内容(中文无法斜体,引用块有视觉区分)
|
|
952
|
-
state.accumulatedContent += `\n> ${block.thinking.replace(/\n/g, "\n> ")}\n`;
|
|
953
|
-
break;
|
|
954
|
-
case "tool_use": {
|
|
955
|
-
// 记录 tool_use 信息供后续 tool_result 使用
|
|
956
|
-
if (toolCallMap && block.id) {
|
|
957
|
-
toolCallMap.set(block.id, { name: block.name, input: block.input });
|
|
958
|
-
}
|
|
959
|
-
const simplified = simplifyToolUse(block.name, block.input);
|
|
960
|
-
if (simplified !== null) {
|
|
961
|
-
state.accumulatedContent += `\n\n${simplified}\n`;
|
|
962
|
-
} else {
|
|
963
|
-
const inputStr =
|
|
964
|
-
typeof block.input === "object"
|
|
965
|
-
? JSON.stringify(block.input)
|
|
966
|
-
: String(block.input ?? "");
|
|
967
|
-
const shortInput =
|
|
968
|
-
inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
|
|
969
|
-
state.accumulatedContent +=
|
|
970
|
-
`\n\n${getToolEmoji(block.name)} **${block.name}**\n\`${shortInput}\`\n`;
|
|
971
|
-
}
|
|
972
|
-
break;
|
|
973
|
-
}
|
|
974
|
-
case "tool_result": {
|
|
975
|
-
const toolUseId = block.tool_use_id;
|
|
976
|
-
const isError = block.is_error;
|
|
977
|
-
// 查找对应的 tool_use 以获取工具名和输入
|
|
978
|
-
const toolCall = toolCallMap?.get(toolUseId);
|
|
979
|
-
const toolName = toolCall?.name;
|
|
980
|
-
const toolInput = toolCall?.input;
|
|
981
|
-
const simplified = toolName
|
|
982
|
-
? simplifyToolResult(toolName, toolUseId, !!isError, toolInput)
|
|
983
|
-
: null;
|
|
984
|
-
if (simplified !== null) {
|
|
985
|
-
state.accumulatedContent += `${simplified}\n`;
|
|
986
|
-
} else {
|
|
987
|
-
const resultContent = block.content;
|
|
988
|
-
let resultStr = "";
|
|
989
|
-
if (typeof resultContent === "string") {
|
|
990
|
-
resultStr = resultContent;
|
|
991
|
-
} else if (Array.isArray(resultContent)) {
|
|
992
|
-
resultStr = resultContent
|
|
993
|
-
.map((c: { type?: string; text?: string }) => c.text ?? "")
|
|
994
|
-
.join("");
|
|
995
|
-
} else if (resultContent) {
|
|
996
|
-
resultStr = JSON.stringify(resultContent);
|
|
997
|
-
}
|
|
998
|
-
const shortResult =
|
|
999
|
-
resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
|
|
1000
|
-
const icon = isError ? "❌" : "✅"; // ❌ : ✅
|
|
1001
|
-
state.accumulatedContent +=
|
|
1002
|
-
`${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
|
|
1003
|
-
}
|
|
1004
|
-
break;
|
|
1005
|
-
}
|
|
1006
|
-
case "redacted_thinking":
|
|
1007
|
-
state.accumulatedContent += "\n\n⚠️ 内容被安全过滤\n"; // ⚠️
|
|
1008
|
-
break;
|
|
1009
|
-
case "search_result":
|
|
1010
|
-
state.accumulatedContent +=
|
|
1011
|
-
`\n\n🔍 联网搜索: **${block.query}**\n`; // 🔍
|
|
1012
|
-
break;
|
|
1013
|
-
case "text":
|
|
1014
|
-
state.finalText += block.text;
|
|
1015
|
-
// 新的增量文本到达时清空 finalCompleteText,确保 pickFinalReply 回退到
|
|
1016
|
-
// finalText(累积文本)。否则 Cursor buffered flush 设置的旧
|
|
1017
|
-
// finalCompleteText 会"吞掉"工具调用后新到达的增量文本。
|
|
1018
|
-
state.finalCompleteText = "";
|
|
1019
|
-
break;
|
|
1020
|
-
case "text_final":
|
|
1021
|
-
// 覆盖而非追加:适配器已保证这是一段完整最终文本(如 Cursor 流末快照)
|
|
1022
|
-
state.finalCompleteText = block.text;
|
|
1023
|
-
break;
|
|
1024
|
-
case "compact_boundary": {
|
|
1025
|
-
const triggerLabel = block.trigger === "manual" ? "手动" : "自动"; // 手动 / 自动
|
|
1026
|
-
state.accumulatedContent +=
|
|
1027
|
-
`\n\n🔄 上下文压缩(${triggerLabel}): **${block.pre_tokens}** → **${block.post_tokens}** tokens\n`; // 🔄 / →
|
|
1028
|
-
break;
|
|
1029
|
-
}
|
|
1030
|
-
case "agent_status":
|
|
1031
|
-
break;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// ---------------------------------------------------------------------------
|
|
1036
|
-
// switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
|
|
1037
|
-
// ---------------------------------------------------------------------------
|
|
1038
|
-
//
|
|
1039
|
-
// 设计契约(解决三类历史 bug):
|
|
1040
|
-
//
|
|
1041
|
-
// 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
|
|
1042
|
-
// 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
|
|
1043
|
-
// 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
|
|
1044
|
-
//
|
|
1045
|
-
// 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
|
|
1046
|
-
// 先 unbind 旧 → bind 新 → 再调 updateChatInfo
|
|
1047
|
-
// API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
|
|
1048
|
-
// 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
|
|
1049
|
-
// 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
|
|
1050
|
-
//
|
|
1051
|
-
// 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
|
|
1052
|
-
// 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
|
|
1053
|
-
// 里成为可清理的 orphan。
|
|
1054
|
-
//
|
|
1055
|
-
// 调用方约定:
|
|
1056
|
-
// - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
|
|
1057
|
-
// - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
|
|
1058
|
-
// - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
|
|
1059
|
-
// - API 失败时:
|
|
1060
|
-
// * 不动内存绑定 / sessionInfoMap / displayCards
|
|
1061
|
-
// * 返回 { ok: false, error }
|
|
1062
|
-
// * 调用方负责把错误反馈给用户
|
|
1063
|
-
// ---------------------------------------------------------------------------
|
|
1064
|
-
|
|
1065
|
-
export interface SwitchChatBindingArgs {
|
|
1066
|
-
chatId: string;
|
|
1067
|
-
chatType: string;
|
|
1068
|
-
oldSessionId: string | null;
|
|
1069
|
-
newSessionId: string;
|
|
1070
|
-
tool: string;
|
|
1071
|
-
/** 群名(私聊忽略) */
|
|
1072
|
-
chatName: string;
|
|
1073
|
-
/** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
|
|
1074
|
-
newDescription: string;
|
|
1075
|
-
/** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
|
|
1076
|
-
initialTurnCount?: number;
|
|
1077
|
-
initialContextTokens?: number;
|
|
1078
|
-
/** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
|
|
1079
|
-
updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
export interface SwitchChatBindingResult {
|
|
1083
|
-
ok: boolean;
|
|
1084
|
-
error?: Error;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
|
|
1088
|
-
const {
|
|
1089
|
-
chatId,
|
|
1090
|
-
chatType,
|
|
1091
|
-
oldSessionId,
|
|
1092
|
-
newSessionId,
|
|
1093
|
-
tool,
|
|
1094
|
-
chatName,
|
|
1095
|
-
newDescription,
|
|
1096
|
-
initialTurnCount = 0,
|
|
1097
|
-
initialContextTokens = 0,
|
|
1098
|
-
updateChatInfoFn,
|
|
1099
|
-
} = args;
|
|
1100
|
-
|
|
1101
|
-
// Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
|
|
1102
|
-
// 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
|
|
1103
|
-
if (chatType !== "p2p") {
|
|
1104
|
-
try {
|
|
1105
|
-
await updateChatInfoFn(chatId, chatName, newDescription);
|
|
1106
|
-
} catch (err) {
|
|
1107
|
-
// API 失败:完全不动内存,调用方负责回报用户。
|
|
1108
|
-
return { ok: false, error: err as Error };
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
|
|
1113
|
-
// 这一段全是同步 Map 操作,不会失败。
|
|
1114
|
-
if (oldSessionId) {
|
|
1115
|
-
unbindChatFromSession(oldSessionId, chatId);
|
|
1116
|
-
displayCards.delete(chatId);
|
|
1117
|
-
cancelQueuedMessage(oldSessionId);
|
|
1118
|
-
}
|
|
1119
|
-
bindChatToSession(newSessionId, chatId);
|
|
1120
|
-
recordLastActiveChat(newSessionId, chatId);
|
|
1121
|
-
|
|
1122
|
-
const now = Date.now();
|
|
1123
|
-
sessionInfoMap.set(chatId, {
|
|
1124
|
-
sessionId: newSessionId,
|
|
1125
|
-
turnCount: initialTurnCount,
|
|
1126
|
-
lastContextTokens: initialContextTokens,
|
|
1127
|
-
startTime: now,
|
|
1128
|
-
tool,
|
|
1129
|
-
});
|
|
1130
|
-
|
|
1131
|
-
// Step 3: 持久化(registry + sessions.json)。
|
|
1132
|
-
// 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
|
|
1133
|
-
await recordSessionRegistry({
|
|
1134
|
-
chatId,
|
|
1135
|
-
sessionId: newSessionId,
|
|
1136
|
-
tool,
|
|
1137
|
-
chatType,
|
|
1138
|
-
chatName,
|
|
1139
|
-
turnCount: initialTurnCount,
|
|
1140
|
-
lastContextTokens: initialContextTokens,
|
|
1141
|
-
startTime: now,
|
|
1142
|
-
running: false,
|
|
1143
|
-
});
|
|
1144
|
-
await saveSessionTool(newSessionId, tool, chatName);
|
|
1145
|
-
|
|
1146
|
-
return { ok: true };
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// ---------------------------------------------------------------------------
|
|
1150
|
-
// AI tool session management
|
|
1151
|
-
// ---------------------------------------------------------------------------
|
|
1152
|
-
|
|
1153
|
-
/**
|
|
1154
|
-
* 日志用:把 tool 对应的"配置摘要"格式化为单行字符串。
|
|
1155
|
-
* Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
|
|
1156
|
-
* cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
|
|
1157
|
-
*/
|
|
1158
|
-
function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
|
|
1159
|
-
if (tool === "cursor") {
|
|
1160
|
-
return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
|
|
1161
|
-
}
|
|
1162
|
-
if (tool === "codex") {
|
|
1163
|
-
const m = getEffectiveModelForTool(tool, sessionId);
|
|
1164
|
-
const e = getEffectiveEffortForTool(tool, sessionId);
|
|
1165
|
-
const modelStr = m.trim() !== "" ? m : "(由 codex config.toml 决定)";
|
|
1166
|
-
const effortStr = e.trim() !== ""
|
|
1167
|
-
? `effort=${e}`
|
|
1168
|
-
: "effort=(由 codex config.toml 决定)";
|
|
1169
|
-
return `model=${modelStr}, ${effortStr}, fast=${getEffectiveFastModeForTool(tool, sessionId) ? "on" : "off"}`;
|
|
1170
|
-
}
|
|
1171
|
-
if (tool === "ccc") {
|
|
1172
|
-
const m = getEffectiveModelForTool(tool, sessionId);
|
|
1173
|
-
const modelStr = m.trim() !== "" ? m : "(not configured)";
|
|
1174
|
-
return `model=${modelStr}, baseURL=${config.ccc.DEEPSEEK_BASE_URL}`;
|
|
1175
|
-
}
|
|
1176
|
-
return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
1180
|
-
const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
|
|
1181
|
-
const adapter = getAdapterForTool(tool);
|
|
1182
|
-
console.log(
|
|
1183
|
-
`[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
|
|
1184
|
-
);
|
|
1185
|
-
|
|
1186
|
-
// Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
|
|
1187
|
-
// 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
|
|
1188
|
-
// 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
|
|
1189
|
-
const createController = new AbortController();
|
|
1190
|
-
let createTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
1191
|
-
const timeoutError = new Error(
|
|
1192
|
-
`${adapter.displayName} session creation timed out after 3 minutes without an init event`,
|
|
1193
|
-
);
|
|
1194
|
-
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1195
|
-
createTimeout = setTimeout(() => {
|
|
1196
|
-
// 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
|
|
1197
|
-
reject(timeoutError);
|
|
1198
|
-
createController.abort();
|
|
1199
|
-
}, responseStallTimeoutMs);
|
|
1200
|
-
createTimeout.unref?.();
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
let result: Awaited<ReturnType<ToolAdapter["createSession"]>>;
|
|
1204
|
-
try {
|
|
1205
|
-
result = await Promise.race([
|
|
1206
|
-
adapter.createSession(cwd, createController.signal),
|
|
1207
|
-
timeoutPromise,
|
|
1208
|
-
]);
|
|
1209
|
-
} finally {
|
|
1210
|
-
if (createTimeout) clearTimeout(createTimeout);
|
|
1211
|
-
}
|
|
1212
|
-
const sessionId = result.sessionId;
|
|
1213
|
-
console.log(`[${ts()}] → sessionId: ${sessionId}`);
|
|
1214
|
-
|
|
1215
|
-
await saveSessionTool(sessionId, tool);
|
|
1216
|
-
|
|
1217
|
-
await addRecentDir(cwd);
|
|
1218
|
-
|
|
1219
|
-
return { sessionId, cwd };
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
export async function resumeAndPrompt(
|
|
1223
|
-
sessionId: string,
|
|
1224
|
-
userText: string,
|
|
1225
|
-
platform: PlatformAdapter,
|
|
1226
|
-
chatId: string,
|
|
1227
|
-
msgTimestamp: number,
|
|
1228
|
-
tool: string,
|
|
1229
|
-
traceId?: string,
|
|
1230
|
-
): Promise<SessionRunOutcome> {
|
|
1231
|
-
return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
// ---------------------------------------------------------------------------
|
|
1235
|
-
// runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
|
|
1236
|
-
// ---------------------------------------------------------------------------
|
|
1237
|
-
|
|
1238
|
-
interface RunAgentSessionOptions {
|
|
1239
|
-
/**
|
|
1240
|
-
* 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
|
|
1241
|
-
* 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
|
|
1242
|
-
*/
|
|
1243
|
-
autoRecovery?: boolean;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
export type SessionRunOutcome = "busy" | "done" | "stopped" | "error" | "auto_ended";
|
|
1247
|
-
|
|
1248
|
-
export async function runAgentSession(
|
|
1249
|
-
sessionId: string,
|
|
1250
|
-
userText: string,
|
|
1251
|
-
platform: PlatformAdapter,
|
|
1252
|
-
_chatId: string,
|
|
1253
|
-
msgTimestamp: number,
|
|
1254
|
-
tool: string,
|
|
1255
|
-
traceId?: string,
|
|
1256
|
-
options: RunAgentSessionOptions = {},
|
|
1257
|
-
): Promise<SessionRunOutcome> {
|
|
1258
|
-
const tid = traceId ?? "";
|
|
1259
|
-
|
|
1260
|
-
// runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
|
|
1261
|
-
// prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
|
|
1262
|
-
// 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
|
|
1263
|
-
// 相同的卡片、状态和收尾行为。
|
|
1264
|
-
const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
|
|
1265
|
-
if (previousSessionId && previousSessionId !== sessionId) {
|
|
1266
|
-
unbindChatFromSession(previousSessionId, _chatId);
|
|
1267
|
-
}
|
|
1268
|
-
bindChatToSession(sessionId, _chatId);
|
|
1269
|
-
|
|
1270
|
-
// 记录用户最后发送消息的群(display loop 只推送到该群)
|
|
1271
|
-
// 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
|
|
1272
|
-
recordChatPlatform(_chatId, platform);
|
|
1273
|
-
recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
|
|
1274
|
-
|
|
1275
|
-
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
1276
|
-
if (activePrompts.has(sessionId)) {
|
|
1277
|
-
if (tid) logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
|
|
1278
|
-
console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
|
|
1279
|
-
const isWechatBusy = platform.kind === "wechat";
|
|
1280
|
-
const busyMsg = isWechatBusy
|
|
1281
|
-
? "当前正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。"
|
|
1282
|
-
: "该会话正在生成回复中,请等待完成后再发送消息。也可以发送 /stop 结束,已完成的步骤不会丢失。";
|
|
1283
|
-
await platform.sendText(_chatId, busyMsg).catch(() => {});
|
|
1284
|
-
return "busy";
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// 立即标记活跃,确保 /sessions、isSessionRunning 等查询在异步准备阶段就能看到运行状态。
|
|
1288
|
-
// 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
|
|
1289
|
-
const controller = new AbortController();
|
|
1290
|
-
const now = Date.now();
|
|
1291
|
-
activePrompts.set(sessionId, {
|
|
1292
|
-
controller,
|
|
1293
|
-
stopped: false,
|
|
1294
|
-
startTime: now,
|
|
1295
|
-
autoRecovery: options.autoRecovery === true,
|
|
1296
|
-
finalResponseObserved: false,
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
// 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
|
|
1300
|
-
const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
|
|
1301
|
-
if (data.sessionId !== sessionId) return;
|
|
1302
|
-
const prompt = activePrompts.get(sessionId);
|
|
1303
|
-
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
|
|
1304
|
-
prompt.resourceStuck = true;
|
|
1305
|
-
|
|
1306
|
-
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1307
|
-
const p = chatId ? platformForChat(chatId) : null;
|
|
1308
|
-
if (chatId && p) {
|
|
1309
|
-
p.sendText(
|
|
1310
|
-
chatId,
|
|
1311
|
-
`⚠️ 会话僵死:session ${sessionId.slice(0, 8)} 对应的 CLI 进程 PID ${data.pid} CPU 和内存连续 ${data.idleMinutes} 分钟无变化,已强制停止。若回复不完整,请重新发送上一条指令。`,
|
|
1312
|
-
).catch(() => {});
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
controller.abort();
|
|
1316
|
-
};
|
|
1317
|
-
resourceMonitor.on("stuck", onResourceStuck);
|
|
1318
|
-
|
|
1319
|
-
// 异步准备工作(session info、IM skills prompt 等)
|
|
1320
|
-
let adapter: ToolAdapter;
|
|
1321
|
-
let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
|
|
1322
|
-
let cwd: string;
|
|
1323
|
-
try {
|
|
1324
|
-
adapter = getAdapterForTool(tool, sessionId);
|
|
1325
|
-
info = await adapter.getSessionInfo(sessionId);
|
|
1326
|
-
cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
|
|
1327
|
-
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
|
|
1328
|
-
console.log(
|
|
1329
|
-
`[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`
|
|
1330
|
-
);
|
|
1331
|
-
|
|
1332
|
-
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
1333
|
-
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
1334
|
-
const wechatImageSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-image-skill");
|
|
1335
|
-
const wechatFileSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-file-skill");
|
|
1336
|
-
const wechatVideoSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-video-skill");
|
|
1337
|
-
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
1338
|
-
const skillVariables = {
|
|
1339
|
-
cwd,
|
|
1340
|
-
session_id: sessionId,
|
|
1341
|
-
im_skills_cache_dir: imSkillsCacheDir,
|
|
1342
|
-
delegate_task_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/delegate-task`,
|
|
1343
|
-
send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
|
|
1344
|
-
send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
|
|
1345
|
-
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
1346
|
-
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
1347
|
-
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
1348
|
-
wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
|
|
1349
|
-
wechat_send_file_script: join(wechatFileSkillDir, "send-file.mjs"),
|
|
1350
|
-
wechat_send_video_script: join(wechatVideoSkillDir, "send-video.mjs"),
|
|
1351
|
-
};
|
|
1352
|
-
const enabledSkillNames = imSkillNamesForPlatform(platform);
|
|
1353
|
-
var imSkillsPrompt = await buildImSkillsPromptCached({ variables: skillVariables, enabledSkillNames });
|
|
1354
|
-
await exportSkillSubDocs({ variables: skillVariables, enabledSkillNames }, imSkillsCacheDir);
|
|
1355
|
-
var userTextWithCapabilities = [
|
|
1356
|
-
...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
|
|
1357
|
-
"[User message]",
|
|
1358
|
-
userText,
|
|
1359
|
-
"[/User message]",
|
|
1360
|
-
].join("\n");
|
|
1361
|
-
} catch (preambleErr) {
|
|
1362
|
-
// 准备工作失败,清理活跃标记,避免"僵尸"活跃状态阻塞后续消息
|
|
1363
|
-
activePrompts.delete(sessionId);
|
|
1364
|
-
throw preambleErr;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
// 更新 sessionInfoMap(所有绑定群共用)
|
|
1368
|
-
const existingInfo = sessionInfoMap.get(_chatId);
|
|
1369
|
-
const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
|
|
1370
|
-
const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
|
|
1371
|
-
// 对所有绑定的 chatId 更新 sessionInfoMap
|
|
1372
|
-
for (const cid of getChatsForSession(sessionId)) {
|
|
1373
|
-
const ei = sessionInfoMap.get(cid);
|
|
1374
|
-
sessionInfoMap.set(cid, {
|
|
1375
|
-
sessionId,
|
|
1376
|
-
turnCount: nextTurnCount,
|
|
1377
|
-
lastContextTokens: nextContextTokens,
|
|
1378
|
-
startTime: now,
|
|
1379
|
-
tool,
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
// 确保触发群也在 map 中
|
|
1383
|
-
if (!sessionInfoMap.has(_chatId)) {
|
|
1384
|
-
sessionInfoMap.set(_chatId, {
|
|
1385
|
-
sessionId,
|
|
1386
|
-
turnCount: nextTurnCount,
|
|
1387
|
-
lastContextTokens: nextContextTokens,
|
|
1388
|
-
startTime: now,
|
|
1389
|
-
tool,
|
|
1390
|
-
});
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
await recordSessionRegistry({
|
|
1394
|
-
chatId: _chatId,
|
|
1395
|
-
sessionId,
|
|
1396
|
-
tool,
|
|
1397
|
-
turnCount: nextTurnCount,
|
|
1398
|
-
lastContextTokens: nextContextTokens,
|
|
1399
|
-
startTime: now,
|
|
1400
|
-
running: true,
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
// 在覆盖 stream state 前,先终结上一轮的展示卡片并发送最终回复。
|
|
1404
|
-
// 竞态根因:上一轮 finally 写入 "done" 状态后,200ms setTimeout 即启动
|
|
1405
|
-
// 新一轮 runAgentSession,立即覆盖为 "running" 状态并 kill 旧 display
|
|
1406
|
-
// loop。旧 loop 的 3s 间隔 tick 只有约 6.7% 概率在 200ms 窗口内命中,
|
|
1407
|
-
// 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
|
|
1408
|
-
// 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
|
|
1409
|
-
// 再开始缓存问题对应的任务"。
|
|
1410
|
-
const prevState = await readStreamState(sessionId);
|
|
1411
|
-
if (prevState && prevState.status !== "running") {
|
|
1412
|
-
const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply, prevState.terminalError);
|
|
1413
|
-
const displayChatId = pickDisplayChat(sessionId);
|
|
1414
|
-
if (displayChatId) {
|
|
1415
|
-
const pp = platformForChat(displayChatId);
|
|
1416
|
-
const display = displayCards.get(displayChatId);
|
|
1417
|
-
|
|
1418
|
-
if (display && pp) {
|
|
1419
|
-
// 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
|
|
1420
|
-
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
1421
|
-
|
|
1422
|
-
// 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
|
|
1423
|
-
// terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
|
|
1424
|
-
// 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
|
|
1425
|
-
if (displayCards.get(displayChatId) !== display) {
|
|
1426
|
-
const finalStatus = turnFinalStatus(prevState.status);
|
|
1427
|
-
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1428
|
-
setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
|
|
1429
|
-
} else {
|
|
1430
|
-
const nextSeq = display.sequence + 1;
|
|
1431
|
-
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status, prevState.terminalError);
|
|
1432
|
-
const cardContent = truncateContent(formatTerminalCardContent(prevState)) || " ";
|
|
1433
|
-
const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
|
|
1434
|
-
await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
|
|
1435
|
-
console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${(err as Error).message}`);
|
|
1436
|
-
});
|
|
1437
|
-
// cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
|
|
1438
|
-
const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
|
|
1439
|
-
displayCards.delete(displayChatId);
|
|
1440
|
-
|
|
1441
|
-
// 持久化:标记上一轮所有卡片为终态
|
|
1442
|
-
const finalStatus = turnFinalStatus(prevState.status);
|
|
1443
|
-
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1444
|
-
|
|
1445
|
-
if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
1446
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1447
|
-
}
|
|
1448
|
-
setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
|
|
1449
|
-
}
|
|
1450
|
-
} else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
|
|
1451
|
-
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
1452
|
-
const finalStatus = turnFinalStatus(prevState.status);
|
|
1453
|
-
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1454
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1455
|
-
}
|
|
1456
|
-
// else: displayCards 无记录且无 finalReply → 无需处理
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
// 初始化 stream-state.json
|
|
1461
|
-
const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
|
|
1462
|
-
const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
|
|
1463
|
-
await writeStreamState(initialState);
|
|
1464
|
-
|
|
1465
|
-
// 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
|
|
1466
|
-
// 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
|
|
1467
|
-
const displayChatIdForNew = pickDisplayChat(sessionId);
|
|
1468
|
-
if (displayChatIdForNew) {
|
|
1469
|
-
const ppNew = platformForChat(displayChatIdForNew);
|
|
1470
|
-
if (ppNew && ppNew.kind !== "wechat") {
|
|
1471
|
-
const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
|
|
1472
|
-
const cardId = await createVisibleProgressCard(
|
|
1473
|
-
ppNew,
|
|
1474
|
-
displayChatIdForNew,
|
|
1475
|
-
sessionId,
|
|
1476
|
-
nextTurnCount,
|
|
1477
|
-
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
1478
|
-
initialHeaderTitle,
|
|
1479
|
-
);
|
|
1480
|
-
if (cardId) {
|
|
1481
|
-
displayCards.set(displayChatIdForNew, {
|
|
1482
|
-
cardId,
|
|
1483
|
-
sequence: 1,
|
|
1484
|
-
cardBusy: false,
|
|
1485
|
-
cardCreatedAt: Date.now(),
|
|
1486
|
-
lastSentContent: "",
|
|
1487
|
-
lastSentHeaderTitle: initialHeaderTitle,
|
|
1488
|
-
streamErrorNotified: false,
|
|
1489
|
-
sessionId,
|
|
1490
|
-
turnCount: nextTurnCount,
|
|
1491
|
-
dotCount: 0,
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
} else if (ppNew && ppNew.kind === "wechat") {
|
|
1495
|
-
// WeChat: 无卡片,但需要 display entry 追踪已发送内容
|
|
1496
|
-
displayCards.set(displayChatIdForNew, {
|
|
1497
|
-
cardId: "",
|
|
1498
|
-
sequence: 0,
|
|
1499
|
-
cardBusy: false,
|
|
1500
|
-
cardCreatedAt: Date.now(),
|
|
1501
|
-
lastSentContent: "",
|
|
1502
|
-
streamErrorNotified: false,
|
|
1503
|
-
sessionId,
|
|
1504
|
-
turnCount: nextTurnCount,
|
|
1505
|
-
dotCount: 0,
|
|
1506
|
-
});
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// 设置最后活跃群头像为 busy
|
|
1511
|
-
refreshBusySessionAvatar(sessionId, tool, platform).catch(() => {});
|
|
1512
|
-
|
|
1513
|
-
const state: AccumulatorState = {
|
|
1514
|
-
accumulatedContent: "",
|
|
1515
|
-
finalText: "",
|
|
1516
|
-
finalCompleteText: "",
|
|
1517
|
-
chunkCount: 0,
|
|
1518
|
-
};
|
|
1519
|
-
|
|
1520
|
-
let lastFileWrite = Date.now();
|
|
1521
|
-
const FILE_WRITE_INTERVAL_MS = 2000;
|
|
1522
|
-
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
1523
|
-
let streamErrored = false;
|
|
1524
|
-
let streamTerminalError: TerminalErrorInfo | undefined;
|
|
1525
|
-
let runOutcome: SessionRunOutcome = "error";
|
|
1526
|
-
const responseStallDetectionEnabled = adapter.responseStallDetectionEnabled !== false;
|
|
1527
|
-
|
|
1528
|
-
const runningPrompt = activePrompts.get(sessionId);
|
|
1529
|
-
if (runningPrompt) {
|
|
1530
|
-
startPromptAvatarRefresh(sessionId, tool, platform, runningPrompt);
|
|
1531
|
-
|
|
1532
|
-
// 在消费第一个事件前建立阶段感知的零字符基线;启动阶段不计时,只有后续
|
|
1533
|
-
// 收到明确的 responding 状态后才会启动三分钟回复停滞保护。
|
|
1534
|
-
runningPrompt.responseProgress = observeResponseProgress(
|
|
1535
|
-
undefined,
|
|
1536
|
-
monitorsOutputProgress(activityTracker.activity.kind),
|
|
1537
|
-
0,
|
|
1538
|
-
activityTracker.activity.startedAt,
|
|
1539
|
-
);
|
|
1540
|
-
|
|
1541
|
-
const checkResponseStall = async () => {
|
|
1542
|
-
const current = activePrompts.get(sessionId);
|
|
1543
|
-
if (!current || current !== runningPrompt) {
|
|
1544
|
-
clearPromptResponseStallMonitor(sessionId);
|
|
1545
|
-
return;
|
|
1546
|
-
}
|
|
1547
|
-
if (
|
|
1548
|
-
current.stopped
|
|
1549
|
-
|| current.abnormalExit
|
|
1550
|
-
|| current.resourceStuck
|
|
1551
|
-
|| current.autoEnded
|
|
1552
|
-
|| current.finalResponseObserved
|
|
1553
|
-
|| !monitorsOutputProgress(activityTracker.activity.kind)
|
|
1554
|
-
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1555
|
-
) {
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
const autoEndedAt = Date.now();
|
|
1560
|
-
// 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
|
|
1561
|
-
// 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
|
|
1562
|
-
// 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
|
|
1563
|
-
// 若当前已经是恢复轮,则不再预约第三轮。
|
|
1564
|
-
if (!current.autoRecovery) {
|
|
1565
|
-
reserveAutoRecovery(sessionId);
|
|
1566
|
-
}
|
|
1567
|
-
current.autoEnded = true;
|
|
1568
|
-
current.autoEndedAt = autoEndedAt;
|
|
1569
|
-
clearPromptResponseStallMonitor(sessionId);
|
|
1570
|
-
clearPromptProcessMonitor(sessionId);
|
|
1571
|
-
|
|
1572
|
-
// First publish an atomic terminal state so the card cannot keep claiming the
|
|
1573
|
-
// Agent is running while process cleanup is underway.
|
|
1574
|
-
await writeStreamState({
|
|
1575
|
-
sessionId,
|
|
1576
|
-
status: "auto_ended",
|
|
1577
|
-
accumulatedContent: state.accumulatedContent,
|
|
1578
|
-
finalReply: pickFinalReply(state).trim(),
|
|
1579
|
-
activity: activityTracker.activity,
|
|
1580
|
-
chunkCount: state.chunkCount,
|
|
1581
|
-
turnCount: nextTurnCount,
|
|
1582
|
-
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1583
|
-
updatedAt: autoEndedAt,
|
|
1584
|
-
cwd,
|
|
1585
|
-
tool,
|
|
1586
|
-
autoEndedAt,
|
|
1587
|
-
});
|
|
1588
|
-
|
|
1589
|
-
// 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
|
|
1590
|
-
// final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
|
|
1591
|
-
if (current.finalResponseObserved) {
|
|
1592
|
-
current.autoEnded = false;
|
|
1593
|
-
current.autoEndedAt = undefined;
|
|
1594
|
-
cancelAutoRecoveryReservation(sessionId);
|
|
1595
|
-
console.log(
|
|
1596
|
-
`[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
|
|
1597
|
-
);
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
try {
|
|
1602
|
-
current.closeSession?.();
|
|
1603
|
-
} catch (err) {
|
|
1604
|
-
console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
|
|
1605
|
-
}
|
|
1606
|
-
current.controller.abort();
|
|
1607
|
-
await killProcessTree(current.processPid);
|
|
1608
|
-
console.warn(
|
|
1609
|
-
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without reply progress`,
|
|
1610
|
-
);
|
|
1611
|
-
};
|
|
1612
|
-
|
|
1613
|
-
if (responseStallDetectionEnabled) {
|
|
1614
|
-
const responseStallMonitor = setInterval(() => {
|
|
1615
|
-
void checkResponseStall().catch((err) => {
|
|
1616
|
-
console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
|
|
1617
|
-
});
|
|
1618
|
-
}, responseStallCheckIntervalMs);
|
|
1619
|
-
responseStallMonitor.unref?.();
|
|
1620
|
-
runningPrompt.responseStallMonitor = responseStallMonitor;
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
try {
|
|
1625
|
-
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
|
|
1626
|
-
onProcessStart: (processInfo) => {
|
|
1627
|
-
startPromptProcessMonitor(sessionId, processInfo);
|
|
1628
|
-
if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
|
|
1629
|
-
},
|
|
1630
|
-
onProcessExit: (exitInfo) => {
|
|
1631
|
-
clearPromptProcessMonitor(sessionId);
|
|
1632
|
-
if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
|
|
1633
|
-
},
|
|
1634
|
-
onSessionCreated: (closeSession) => {
|
|
1635
|
-
const prompt = activePrompts.get(sessionId);
|
|
1636
|
-
if (prompt) prompt.closeSession = closeSession;
|
|
1637
|
-
},
|
|
1638
|
-
})) {
|
|
1639
|
-
if (unifiedMsg.isFinalResponse) {
|
|
1640
|
-
const prompt = activePrompts.get(sessionId);
|
|
1641
|
-
if (prompt && prompt === runningPrompt) {
|
|
1642
|
-
// 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
|
|
1643
|
-
// 最终事件后仍把本轮判为停滞。
|
|
1644
|
-
if (!prompt.finalResponseObserved) {
|
|
1645
|
-
prompt.finalResponseObserved = true;
|
|
1646
|
-
scheduleFinalResponseCloseGuard(sessionId, prompt);
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
let activityChanged = false;
|
|
1652
|
-
for (const block of unifiedMsg.blocks) {
|
|
1653
|
-
if (updateAgentActivity(activityTracker, block)) activityChanged = true;
|
|
1654
|
-
accumulateBlockContent(block, state, toolCallMap);
|
|
1655
|
-
|
|
1656
|
-
if (block.type === "compact_boundary" && block.post_tokens) {
|
|
1657
|
-
for (const cid of getChatsForSession(sessionId)) {
|
|
1658
|
-
const sinfo = sessionInfoMap.get(cid);
|
|
1659
|
-
if (sinfo) sinfo.lastContextTokens = block.post_tokens;
|
|
1660
|
-
}
|
|
1661
|
-
await recordSessionRegistry({
|
|
1662
|
-
chatId: _chatId,
|
|
1663
|
-
sessionId,
|
|
1664
|
-
tool,
|
|
1665
|
-
lastContextTokens: block.post_tokens,
|
|
1666
|
-
running: true,
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
const prompt = activePrompts.get(sessionId);
|
|
1672
|
-
if (prompt && !prompt.autoEnded) {
|
|
1673
|
-
const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
|
|
1674
|
-
prompt.responseProgress = observeResponseProgress(
|
|
1675
|
-
// starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
|
|
1676
|
-
// 重新计时;其它活动阶段会清空观察窗口。
|
|
1677
|
-
activityChanged ? undefined : prompt.responseProgress,
|
|
1678
|
-
monitorsOutputProgress(activityTracker.activity.kind),
|
|
1679
|
-
totalChars,
|
|
1680
|
-
Date.now(),
|
|
1681
|
-
);
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
// 定时写入文件
|
|
1685
|
-
const now2 = Date.now();
|
|
1686
|
-
if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
|
|
1687
|
-
lastFileWrite = now2;
|
|
1688
|
-
await writeStreamState({
|
|
1689
|
-
sessionId,
|
|
1690
|
-
status: "running",
|
|
1691
|
-
accumulatedContent: state.accumulatedContent,
|
|
1692
|
-
finalReply: pickFinalReply(state),
|
|
1693
|
-
activity: activityTracker.activity,
|
|
1694
|
-
chunkCount: state.chunkCount,
|
|
1695
|
-
turnCount: nextTurnCount,
|
|
1696
|
-
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1697
|
-
updatedAt: now2,
|
|
1698
|
-
cwd,
|
|
1699
|
-
tool,
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
} catch (streamErr) {
|
|
1704
|
-
streamErrored = true;
|
|
1705
|
-
streamTerminalError = classifyTerminalError(streamErr);
|
|
1706
|
-
console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
|
|
1707
|
-
} finally {
|
|
1708
|
-
// 标记 prompt 结束
|
|
1709
|
-
resourceMonitor.off("stuck", onResourceStuck);
|
|
1710
|
-
const prompt = activePrompts.get(sessionId);
|
|
1711
|
-
const wasStopped = prompt?.stopped ?? false;
|
|
1712
|
-
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1713
|
-
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1714
|
-
const timeoutTriggered = prompt?.autoEnded ?? false;
|
|
1715
|
-
const completedAtTimeoutBoundary =
|
|
1716
|
-
timeoutTriggered && (prompt?.finalResponseObserved ?? false);
|
|
1717
|
-
const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
|
|
1718
|
-
const wasAutoRecovery = prompt?.autoRecovery ?? false;
|
|
1719
|
-
const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
|
|
1720
|
-
clearPromptResponseStallMonitor(sessionId);
|
|
1721
|
-
clearPromptProcessMonitor(sessionId);
|
|
1722
|
-
clearPromptAvatarRefreshTimer(sessionId);
|
|
1723
|
-
clearPromptFinalResponseCloseTimer(sessionId);
|
|
1724
|
-
markSessionFinalizing(sessionId);
|
|
1725
|
-
activePrompts.delete(sessionId);
|
|
1726
|
-
|
|
1727
|
-
try {
|
|
1728
|
-
if (completedAtTimeoutBoundary) {
|
|
1729
|
-
// reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
|
|
1730
|
-
// abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
|
|
1731
|
-
cancelAutoRecoveryReservation(sessionId);
|
|
1732
|
-
console.log(
|
|
1733
|
-
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
|
|
1734
|
-
);
|
|
1735
|
-
}
|
|
1736
|
-
// 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
|
|
1737
|
-
// 永久残留 running=true。
|
|
1738
|
-
const finalizationChatIds = [...new Set([
|
|
1739
|
-
...getChatsForSession(sessionId),
|
|
1740
|
-
_chatId,
|
|
1741
|
-
])];
|
|
1742
|
-
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
1743
|
-
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1744
|
-
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1745
|
-
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1746
|
-
const finalStatus = completedAtTimeoutBoundary
|
|
1747
|
-
? "done"
|
|
1748
|
-
: wasAutoEnded
|
|
1749
|
-
? "auto_ended"
|
|
1750
|
-
: (streamErrored || wasAbnormalExit || wasResourceStuck)
|
|
1751
|
-
? "error"
|
|
1752
|
-
: wasStopped
|
|
1753
|
-
? "stopped"
|
|
1754
|
-
: "done";
|
|
1755
|
-
const finalReply = pickFinalReply(state).trim();
|
|
1756
|
-
const terminalError = streamTerminalError
|
|
1757
|
-
?? (wasAbnormalExit
|
|
1758
|
-
? {
|
|
1759
|
-
kind: "process" as const,
|
|
1760
|
-
title: "Agent 进程意外退出",
|
|
1761
|
-
message: "Agent CLI 进程已退出。若回复不完整,请重新发送上一条指令。",
|
|
1762
|
-
occurredAt: Date.now(),
|
|
1763
|
-
}
|
|
1764
|
-
: wasResourceStuck
|
|
1765
|
-
? {
|
|
1766
|
-
kind: "resource" as const,
|
|
1767
|
-
title: "Agent 进程失去响应",
|
|
1768
|
-
message: "Agent 进程长时间没有 CPU 或内存变化,已被强制停止。若回复不完整,请重新发送上一条指令。",
|
|
1769
|
-
occurredAt: Date.now(),
|
|
1770
|
-
}
|
|
1771
|
-
: undefined);
|
|
1772
|
-
runOutcome = finalStatus;
|
|
1773
|
-
|
|
1774
|
-
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
1775
|
-
// stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
|
|
1776
|
-
// stop-stuck-loop 结束后 session 被错误恢复。
|
|
1777
|
-
let finalReplyToWrite = finalReply;
|
|
1778
|
-
let preserveStuckAt: number | undefined;
|
|
1779
|
-
try {
|
|
1780
|
-
const existing = await readStreamState(sessionId);
|
|
1781
|
-
if (existing) {
|
|
1782
|
-
if (existing.finalReply.length > finalReply.length) {
|
|
1783
|
-
finalReplyToWrite = existing.finalReply;
|
|
1784
|
-
}
|
|
1785
|
-
preserveStuckAt = existing.stuckAt;
|
|
1786
|
-
}
|
|
1787
|
-
} catch {}
|
|
1788
|
-
|
|
1789
|
-
await writeStreamState({
|
|
1790
|
-
sessionId,
|
|
1791
|
-
status: finalStatus,
|
|
1792
|
-
accumulatedContent: state.accumulatedContent,
|
|
1793
|
-
finalReply: finalReplyToWrite,
|
|
1794
|
-
activity: activityTracker.activity,
|
|
1795
|
-
chunkCount: state.chunkCount,
|
|
1796
|
-
turnCount: nextTurnCount,
|
|
1797
|
-
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1798
|
-
updatedAt: Date.now(),
|
|
1799
|
-
cwd,
|
|
1800
|
-
tool,
|
|
1801
|
-
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1802
|
-
...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
|
|
1803
|
-
...(terminalError ? { terminalError } : {}),
|
|
1804
|
-
});
|
|
1805
|
-
|
|
1806
|
-
// display loop 下一轮会读到最终状态并发送消息
|
|
1807
|
-
|
|
1808
|
-
let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
|
|
1809
|
-
|
|
1810
|
-
if (wasStopped) {
|
|
1811
|
-
for (const cid of finalizationChatIds) {
|
|
1812
|
-
const finfo = sessionInfoMap.get(cid);
|
|
1813
|
-
await recordSessionRegistry({
|
|
1814
|
-
chatId: cid,
|
|
1815
|
-
sessionId,
|
|
1816
|
-
tool,
|
|
1817
|
-
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1818
|
-
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1819
|
-
startTime: finfo?.startTime ?? now,
|
|
1820
|
-
running: false,
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1824
|
-
if (active1) {
|
|
1825
|
-
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1826
|
-
setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
|
|
1827
|
-
}
|
|
1828
|
-
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1829
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1830
|
-
} else if (wasAutoEnded) {
|
|
1831
|
-
for (const cid of finalizationChatIds) {
|
|
1832
|
-
const finfo = sessionInfoMap.get(cid);
|
|
1833
|
-
await recordSessionRegistry({
|
|
1834
|
-
chatId: cid,
|
|
1835
|
-
sessionId,
|
|
1836
|
-
tool,
|
|
1837
|
-
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1838
|
-
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1839
|
-
startTime: finfo?.startTime ?? now,
|
|
1840
|
-
running: false,
|
|
1841
|
-
});
|
|
1842
|
-
}
|
|
1843
|
-
const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1844
|
-
if (activeAutoEnded) {
|
|
1845
|
-
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1846
|
-
const terminalState = await readStreamState(sessionId);
|
|
1847
|
-
if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1848
|
-
await sendFinalReplyTextOnce(
|
|
1849
|
-
pp,
|
|
1850
|
-
activeAutoEnded,
|
|
1851
|
-
sessionId,
|
|
1852
|
-
nextTurnCount,
|
|
1853
|
-
formatAutoEndedReply(finalReplyToWrite),
|
|
1854
|
-
);
|
|
1855
|
-
}
|
|
1856
|
-
setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
|
|
1857
|
-
|
|
1858
|
-
if (wasAutoRecovery) {
|
|
1859
|
-
// 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
|
|
1860
|
-
// 自动链,避免第三轮及之后的无限续跑。
|
|
1861
|
-
await pp.sendText(
|
|
1862
|
-
activeAutoEnded,
|
|
1863
|
-
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
1864
|
-
).catch(() => {});
|
|
1865
|
-
} else if (hasAutoRecoveryReservation(sessionId)) {
|
|
1866
|
-
// 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
|
|
1867
|
-
// 内部恢复仍由 reservation 保证先于普通缓存消息。
|
|
1868
|
-
await pp.sendText(
|
|
1869
|
-
activeAutoEnded,
|
|
1870
|
-
RESPONSE_STALL_RECOVERY_NOTICE,
|
|
1871
|
-
).catch(() => {});
|
|
1872
|
-
autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1876
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1877
|
-
} else if (wasAbnormalExit) {
|
|
1878
|
-
for (const cid of finalizationChatIds) {
|
|
1879
|
-
const finfo = sessionInfoMap.get(cid);
|
|
1880
|
-
await recordSessionRegistry({
|
|
1881
|
-
chatId: cid,
|
|
1882
|
-
sessionId,
|
|
1883
|
-
tool,
|
|
1884
|
-
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1885
|
-
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1886
|
-
startTime: finfo?.startTime ?? now,
|
|
1887
|
-
running: false,
|
|
1888
|
-
});
|
|
1889
|
-
}
|
|
1890
|
-
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1891
|
-
if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
|
|
1892
|
-
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1893
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1894
|
-
} else if (streamErrored || wasResourceStuck) {
|
|
1895
|
-
for (const cid of finalizationChatIds) {
|
|
1896
|
-
const finfo = sessionInfoMap.get(cid);
|
|
1897
|
-
await recordSessionRegistry({
|
|
1898
|
-
chatId: cid,
|
|
1899
|
-
sessionId,
|
|
1900
|
-
tool,
|
|
1901
|
-
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1902
|
-
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1903
|
-
startTime: finfo?.startTime ?? now,
|
|
1904
|
-
running: false,
|
|
1905
|
-
});
|
|
1906
|
-
}
|
|
1907
|
-
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1908
|
-
if (activeErr) {
|
|
1909
|
-
const pp = platformForChat(activeErr) ?? platform;
|
|
1910
|
-
const terminalState = await readStreamState(sessionId);
|
|
1911
|
-
if (
|
|
1912
|
-
terminalError
|
|
1913
|
-
&& !displayCards.has(activeErr)
|
|
1914
|
-
&& (!terminalState || !isFinalReplySentForTurn(terminalState))
|
|
1915
|
-
) {
|
|
1916
|
-
await sendFinalReplyTextOnce(
|
|
1917
|
-
pp,
|
|
1918
|
-
activeErr,
|
|
1919
|
-
sessionId,
|
|
1920
|
-
nextTurnCount,
|
|
1921
|
-
formatTerminalErrorNotice(terminalError, finalReplyToWrite),
|
|
1922
|
-
);
|
|
1923
|
-
}
|
|
1924
|
-
setSessionChatAvatar(pp, activeErr, tool, "idle", sessionId).catch(() => {});
|
|
1925
|
-
}
|
|
1926
|
-
const errorOutcome = wasResourceStuck ? "resource_stuck" : "stream_error";
|
|
1927
|
-
console.error(
|
|
1928
|
-
`[${ts()}] Session ${sessionId} ended with ${errorOutcome}` +
|
|
1929
|
-
`${terminalError ? ` (${terminalError.kind}: ${terminalError.title})` : ""} (content chunks: ${state.chunkCount})`,
|
|
1930
|
-
);
|
|
1931
|
-
if (tid) {
|
|
1932
|
-
logTrace(tid, "SESSION_END", {
|
|
1933
|
-
sessionId,
|
|
1934
|
-
outcome: errorOutcome,
|
|
1935
|
-
errorKind: terminalError?.kind,
|
|
1936
|
-
errorTitle: terminalError?.title,
|
|
1937
|
-
chunks: state.chunkCount,
|
|
1938
|
-
});
|
|
1939
|
-
}
|
|
1940
|
-
} else {
|
|
1941
|
-
for (const cid of finalizationChatIds) {
|
|
1942
|
-
const finfo = sessionInfoMap.get(cid);
|
|
1943
|
-
await recordSessionRegistry({
|
|
1944
|
-
chatId: cid,
|
|
1945
|
-
sessionId,
|
|
1946
|
-
tool,
|
|
1947
|
-
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1948
|
-
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1949
|
-
startTime: finfo?.startTime ?? now,
|
|
1950
|
-
running: false,
|
|
1951
|
-
});
|
|
1952
|
-
}
|
|
1953
|
-
const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1954
|
-
if (active2) {
|
|
1955
|
-
const terminalState = await readStreamState(sessionId);
|
|
1956
|
-
if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1957
|
-
const pp = platformForChat(active2) ?? platform;
|
|
1958
|
-
await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
|
|
1959
|
-
}
|
|
1960
|
-
setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
|
|
1961
|
-
}
|
|
1962
|
-
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
1963
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
|
|
1967
|
-
if (
|
|
1968
|
-
wasAutoEnded
|
|
1969
|
-
&& hasAutoRecoveryReservation(sessionId)
|
|
1970
|
-
&& !autoRecoveryTarget
|
|
1971
|
-
) {
|
|
1972
|
-
cancelAutoRecoveryReservation(sessionId);
|
|
1973
|
-
}
|
|
1974
|
-
const shouldScheduleAutoRecovery =
|
|
1975
|
-
autoRecoveryTarget !== undefined
|
|
1976
|
-
&& hasAutoRecoveryReservation(sessionId);
|
|
1977
|
-
|
|
1978
|
-
// 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
|
|
1979
|
-
// 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
|
|
1980
|
-
let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
|
|
1981
|
-
if (wasStopped) {
|
|
1982
|
-
const discarded = dequeueMessage(sessionId);
|
|
1983
|
-
if (discarded) {
|
|
1984
|
-
console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
|
|
1985
|
-
}
|
|
1986
|
-
} else if (!shouldScheduleAutoRecovery) {
|
|
1987
|
-
// 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
|
|
1988
|
-
// finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
|
|
1989
|
-
queuedForConsumption = dequeueMessage(sessionId);
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
if (queuedForConsumption) {
|
|
1993
|
-
const queued = queuedForConsumption;
|
|
1994
|
-
// 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
|
|
1995
|
-
// 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
|
|
1996
|
-
// 用保存的 chat 替代 queued.chatId 作为 display 目标)。
|
|
1997
|
-
const preservedChat = getLastActiveChat(sessionId);
|
|
1998
|
-
if (preservedChat && preservedChat !== queued.chatId) {
|
|
1999
|
-
setQueuePreservedChat(sessionId, preservedChat);
|
|
2000
|
-
}
|
|
2001
|
-
console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
|
|
2002
|
-
// setTimeout 而非 setImmediate:给 display loop 的 setInterval
|
|
2003
|
-
// 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
|
|
2004
|
-
setTimeout(() => {
|
|
2005
|
-
consumeQueuedMessage(platform, queued);
|
|
2006
|
-
}, RESPONSE_STALL_RECOVERY_DELAY_MS);
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
|
|
2010
|
-
const target = autoRecoveryTarget;
|
|
2011
|
-
console.log(
|
|
2012
|
-
`[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
|
|
2013
|
-
);
|
|
2014
|
-
// 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
|
|
2015
|
-
// 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
|
|
2016
|
-
// 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
|
|
2017
|
-
// 因而不存在普通用户消息可插入的事件循环空窗。
|
|
2018
|
-
setTimeout(() => {
|
|
2019
|
-
if (!hasAutoRecoveryReservation(sessionId)) return;
|
|
2020
|
-
const recoveryRun = runAgentSession(
|
|
2021
|
-
sessionId,
|
|
2022
|
-
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
2023
|
-
target.platform,
|
|
2024
|
-
target.chatId,
|
|
2025
|
-
Date.now(),
|
|
2026
|
-
tool,
|
|
2027
|
-
undefined,
|
|
2028
|
-
{ autoRecovery: true },
|
|
2029
|
-
);
|
|
2030
|
-
consumeAutoRecoveryReservation(sessionId);
|
|
2031
|
-
void recoveryRun.catch((err) => {
|
|
2032
|
-
console.error(
|
|
2033
|
-
`[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
|
|
2034
|
-
);
|
|
2035
|
-
target.platform.sendText(
|
|
2036
|
-
target.chatId,
|
|
2037
|
-
`⚠️ 自动续跑启动失败:${(err as Error).message}`,
|
|
2038
|
-
).catch(() => {});
|
|
2039
|
-
|
|
2040
|
-
// 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
|
|
2041
|
-
// 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
|
|
2042
|
-
const queued = dequeueMessage(sessionId);
|
|
2043
|
-
if (queued) {
|
|
2044
|
-
consumeQueuedMessage(target.platform, queued);
|
|
2045
|
-
}
|
|
2046
|
-
});
|
|
2047
|
-
}, RESPONSE_STALL_RECOVERY_DELAY_MS);
|
|
2048
|
-
}
|
|
2049
|
-
} finally {
|
|
2050
|
-
clearSessionFinalizing(sessionId);
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
return runOutcome;
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
// ---------------------------------------------------------------------------
|
|
2057
|
-
// startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
|
|
2058
|
-
// ---------------------------------------------------------------------------
|
|
2059
|
-
// 替代旧的 per-session ensureDisplayLoop,消除 kill/restart 竞态条件。
|
|
2060
|
-
// 单一定时器遍历所有 displayCards 条目,通过条目内的 sessionId 查找 stream state。
|
|
2061
|
-
// ---------------------------------------------------------------------------
|
|
2062
|
-
|
|
2063
|
-
const CARD_ROTATE_MS = 9 * 60 * 1000;
|
|
2064
|
-
|
|
2065
|
-
export function startUnifiedDisplayLoop(): void {
|
|
2066
|
-
if (unifiedDisplayLoopHandle !== null) return;
|
|
2067
|
-
|
|
2068
|
-
let tickRunning = false;
|
|
2069
|
-
const interval = setInterval(() => {
|
|
2070
|
-
void (async () => {
|
|
2071
|
-
if (tickRunning) return;
|
|
2072
|
-
tickRunning = true;
|
|
2073
|
-
try {
|
|
2074
|
-
for (const [chatId, display] of displayCards) {
|
|
2075
|
-
if (display.cardBusy) continue;
|
|
2076
|
-
|
|
2077
|
-
const sessionId = display.sessionId;
|
|
2078
|
-
const state = await readStreamState(sessionId);
|
|
2079
|
-
if (!state) {
|
|
2080
|
-
displayCards.delete(chatId);
|
|
2081
|
-
continue;
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
// 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
|
|
2085
|
-
// 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
|
|
2086
|
-
const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
|
|
2087
|
-
if (currentSessionForChat && currentSessionForChat !== sessionId) {
|
|
2088
|
-
if (state.status !== "running") {
|
|
2089
|
-
displayCards.delete(chatId);
|
|
2090
|
-
}
|
|
2091
|
-
continue;
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
// 验证 chat 仍是该 session 的最后活跃群
|
|
2095
|
-
const lastActive = getLastActiveChat(sessionId);
|
|
2096
|
-
if (lastActive !== chatId) {
|
|
2097
|
-
if (state.status !== "running") {
|
|
2098
|
-
displayCards.delete(chatId);
|
|
2099
|
-
}
|
|
2100
|
-
continue;
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
const isTerminal = state.status !== "running";
|
|
2104
|
-
|
|
2105
|
-
try {
|
|
2106
|
-
const p = platformForChat(chatId);
|
|
2107
|
-
if (!p) continue;
|
|
2108
|
-
|
|
2109
|
-
const isWechat = p.kind === "wechat";
|
|
2110
|
-
|
|
2111
|
-
if (isTerminal) {
|
|
2112
|
-
if (isWechat) {
|
|
2113
|
-
const prevAccLen = display.lastSentAccLen ?? 0;
|
|
2114
|
-
const prevFinalReply = display.lastSentFinalReply ?? "";
|
|
2115
|
-
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
2116
|
-
let replyDelta: string;
|
|
2117
|
-
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
2118
|
-
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
2119
|
-
} else {
|
|
2120
|
-
replyDelta = state.finalReply;
|
|
2121
|
-
}
|
|
2122
|
-
const remaining = (accDelta + replyDelta).trim();
|
|
2123
|
-
|
|
2124
|
-
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
2125
|
-
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
2126
|
-
// 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
|
|
2127
|
-
// 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
|
|
2128
|
-
if (activePrompts.has(sessionId)) continue;
|
|
2129
|
-
|
|
2130
|
-
const tail = "━━━ 回答结束 ━━━";
|
|
2131
|
-
const finalMsg = state.status === "auto_ended"
|
|
2132
|
-
? formatAutoEndedReply(remaining)
|
|
2133
|
-
: state.status === "error"
|
|
2134
|
-
? formatTerminalReply(state.status, remaining, state.terminalError) ?? tail
|
|
2135
|
-
: remaining
|
|
2136
|
-
? remaining + "\n" + tail
|
|
2137
|
-
: tail;
|
|
2138
|
-
if (!isFinalReplySentForTurn(state)) {
|
|
2139
|
-
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
|
|
2140
|
-
}
|
|
2141
|
-
displayCards.delete(chatId);
|
|
2142
|
-
} else {
|
|
2143
|
-
// 发送最终结果(卡片平台)
|
|
2144
|
-
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
2145
|
-
const promptStillActive = activePrompts.has(sessionId);
|
|
2146
|
-
if (
|
|
2147
|
-
promptStillActive &&
|
|
2148
|
-
display.lastSentAccLen === state.accumulatedContent.length &&
|
|
2149
|
-
display.lastSentFinalReply === state.finalReply
|
|
2150
|
-
) {
|
|
2151
|
-
continue;
|
|
2152
|
-
}
|
|
2153
|
-
const terminalCardAlreadyUpdated =
|
|
2154
|
-
display.lastSentAccLen === state.accumulatedContent.length &&
|
|
2155
|
-
display.lastSentFinalReply === state.finalReply;
|
|
2156
|
-
let terminalCardUpdateAccepted = terminalCardAlreadyUpdated;
|
|
2157
|
-
if (!terminalCardAlreadyUpdated) {
|
|
2158
|
-
const nextSeq = display.sequence + 1;
|
|
2159
|
-
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status, state.terminalError);
|
|
2160
|
-
const cardContent = truncateContent(formatTerminalCardContent(state)) || " ";
|
|
2161
|
-
const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
|
|
2162
|
-
await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
|
|
2163
|
-
display.sequence = nextSeq;
|
|
2164
|
-
terminalCardUpdateAccepted = true;
|
|
2165
|
-
}).catch(err => {
|
|
2166
|
-
console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${(err as Error).message}`);
|
|
2167
|
-
if (isCardKitSequenceConflict(err)) {
|
|
2168
|
-
display.sequence = nextSeq;
|
|
2169
|
-
terminalCardUpdateAccepted = true;
|
|
2170
|
-
}
|
|
2171
|
-
});
|
|
2172
|
-
if (terminalCardUpdateAccepted) {
|
|
2173
|
-
display.lastSentAccLen = state.accumulatedContent.length;
|
|
2174
|
-
display.lastSentFinalReply = state.finalReply;
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
2179
|
-
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
2180
|
-
// 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
|
|
2181
|
-
// 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
|
|
2182
|
-
if (promptStillActive) {
|
|
2183
|
-
continue;
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
let terminalTextDelivered = true;
|
|
2187
|
-
const terminalReply = formatTerminalReply(state.status, state.finalReply, state.terminalError);
|
|
2188
|
-
const errorWasDeliveredByCard =
|
|
2189
|
-
state.status === "error"
|
|
2190
|
-
&& !state.finalReply.trim()
|
|
2191
|
-
&& terminalCardUpdateAccepted;
|
|
2192
|
-
if (errorWasDeliveredByCard) {
|
|
2193
|
-
if (!isFinalReplySentForTurn(state)) {
|
|
2194
|
-
await markFinalReplySent(sessionId, state.turnCount);
|
|
2195
|
-
}
|
|
2196
|
-
} else if (terminalReply) {
|
|
2197
|
-
if (!isFinalReplySentForTurn(state)) {
|
|
2198
|
-
terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
|
|
2199
|
-
}
|
|
2200
|
-
} else if (state.accumulatedContent.trim()) {
|
|
2201
|
-
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
2202
|
-
terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
|
-
if (!terminalTextDelivered) {
|
|
2206
|
-
console.error(`[${ts()}] [DISPLAY] terminal text send failed, keep display for retry: chatId=${chatId} session=${sessionId} turn=${state.turnCount}`);
|
|
2207
|
-
continue;
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
const finalSt = turnFinalStatus(state.status);
|
|
2211
|
-
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
2212
|
-
displayCards.delete(chatId);
|
|
2213
|
-
}
|
|
2214
|
-
setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
|
|
2215
|
-
console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
|
|
2216
|
-
} else {
|
|
2217
|
-
// running: 创建或更新展示
|
|
2218
|
-
if (isWechat) {
|
|
2219
|
-
// WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
|
|
2220
|
-
const prevAccLen = display.lastSentAccLen ?? 0;
|
|
2221
|
-
const prevFinalReply = display.lastSentFinalReply ?? "";
|
|
2222
|
-
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
2223
|
-
let replyDelta: string;
|
|
2224
|
-
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
2225
|
-
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
2226
|
-
} else {
|
|
2227
|
-
replyDelta = state.finalReply;
|
|
2228
|
-
}
|
|
2229
|
-
const delta = (accDelta + replyDelta).trim();
|
|
2230
|
-
if (!delta) continue;
|
|
2231
|
-
|
|
2232
|
-
display.cardBusy = true;
|
|
2233
|
-
try {
|
|
2234
|
-
const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
|
|
2235
|
-
if (ok) {
|
|
2236
|
-
display.lastSentAccLen = state.accumulatedContent.length;
|
|
2237
|
-
display.lastSentFinalReply = state.finalReply;
|
|
2238
|
-
display.lastSentContent = delta;
|
|
2239
|
-
}
|
|
2240
|
-
} catch (err) {
|
|
2241
|
-
console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
|
|
2242
|
-
if (!display.streamErrorNotified) {
|
|
2243
|
-
display.streamErrorNotified = true;
|
|
2244
|
-
p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
|
|
2245
|
-
}
|
|
2246
|
-
} finally {
|
|
2247
|
-
display.cardBusy = false;
|
|
2248
|
-
}
|
|
2249
|
-
} else {
|
|
2250
|
-
// 非 WeChat: 卡片流程
|
|
2251
|
-
if (display.turnCount !== state.turnCount) {
|
|
2252
|
-
console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
|
|
2253
|
-
finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
|
|
2254
|
-
displayCards.delete(chatId);
|
|
2255
|
-
continue;
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
|
|
2259
|
-
|
|
2260
|
-
// 卡片轮转
|
|
2261
|
-
if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
|
|
2262
|
-
display.cardBusy = true;
|
|
2263
|
-
try {
|
|
2264
|
-
const newCardId = await createVisibleProgressCard(
|
|
2265
|
-
p,
|
|
2266
|
-
chatId,
|
|
2267
|
-
sessionId,
|
|
2268
|
-
display.turnCount,
|
|
2269
|
-
display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
|
|
2270
|
-
activityHeaderTitle,
|
|
2271
|
-
);
|
|
2272
|
-
if (!newCardId) {
|
|
2273
|
-
display.streamErrorNotified = true;
|
|
2274
|
-
continue;
|
|
2275
|
-
}
|
|
2276
|
-
const oldSeqBase = display.sequence;
|
|
2277
|
-
const oldContent = state.accumulatedContent + state.finalReply;
|
|
2278
|
-
const oldCard = buildProgressCard(progressView({ text: truncateContent(oldContent) || " ", status: "done", showStop: false, headerTitle: "上一阶段记录" }));
|
|
2279
|
-
await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
|
|
2280
|
-
display.sequence = oldSeqBase + 1;
|
|
2281
|
-
}).catch(err => {
|
|
2282
|
-
console.error(`[${ts()}] [DISPLAY] rotation old cardUpdate failed: ${(err as Error).message}`);
|
|
2283
|
-
});
|
|
2284
|
-
markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
|
|
2285
|
-
display.cardId = newCardId;
|
|
2286
|
-
display.sequence = 1;
|
|
2287
|
-
display.cardCreatedAt = Date.now();
|
|
2288
|
-
display.rotationAccLen = state.accumulatedContent.length;
|
|
2289
|
-
display.rotationFinalReply = state.finalReply;
|
|
2290
|
-
display.lastSentContent = "";
|
|
2291
|
-
display.lastSentHeaderTitle = activityHeaderTitle;
|
|
2292
|
-
display.streamErrorNotified = false;
|
|
2293
|
-
} catch (err) {
|
|
2294
|
-
console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
|
|
2295
|
-
} finally {
|
|
2296
|
-
display.cardBusy = false;
|
|
2297
|
-
}
|
|
2298
|
-
continue;
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
// 轮转后:分开追踪 accumulatedContent 和 finalReply 增量
|
|
2302
|
-
if (display.rotationAccLen !== undefined) {
|
|
2303
|
-
const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
|
|
2304
|
-
const rotReply = display.rotationFinalReply ?? "";
|
|
2305
|
-
let replyDelta: string;
|
|
2306
|
-
if (rotReply && state.finalReply.startsWith(rotReply)) {
|
|
2307
|
-
replyDelta = state.finalReply.slice(rotReply.length);
|
|
2308
|
-
} else {
|
|
2309
|
-
replyDelta = state.finalReply;
|
|
2310
|
-
}
|
|
2311
|
-
const delta = (accDelta + replyDelta).trim();
|
|
2312
|
-
|
|
2313
|
-
display.dotCount = (display.dotCount % 9) + 1;
|
|
2314
|
-
let deltaBase = delta;
|
|
2315
|
-
if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
|
|
2316
|
-
const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
|
|
2317
|
-
if (
|
|
2318
|
-
displayContent === display.lastSentContent
|
|
2319
|
-
&& activityHeaderTitle === display.lastSentHeaderTitle
|
|
2320
|
-
) continue;
|
|
2321
|
-
|
|
2322
|
-
display.lastSentContent = displayContent;
|
|
2323
|
-
display.lastSentHeaderTitle = activityHeaderTitle;
|
|
2324
|
-
const deltaCard = buildProgressCard(progressView({ text: truncateContent(displayContent) || "等待 Agent 输出...", showStop: true, headerTitle: activityHeaderTitle }));
|
|
2325
|
-
display.cardBusy = true;
|
|
2326
|
-
const mySeq = display.sequence + 1;
|
|
2327
|
-
try {
|
|
2328
|
-
await p.cardUpdate(display.cardId, deltaCard, mySeq);
|
|
2329
|
-
display.sequence = mySeq;
|
|
2330
|
-
} catch (err) {
|
|
2331
|
-
const errMsg = (err as Error).message;
|
|
2332
|
-
console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
|
|
2333
|
-
if (errMsg.includes("300317")) {
|
|
2334
|
-
display.sequence = mySeq;
|
|
2335
|
-
} else if (!display.streamErrorNotified) {
|
|
2336
|
-
display.streamErrorNotified = true;
|
|
2337
|
-
p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
|
|
2338
|
-
}
|
|
2339
|
-
} finally {
|
|
2340
|
-
display.cardBusy = false;
|
|
2341
|
-
}
|
|
2342
|
-
continue;
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
display.dotCount = (display.dotCount % 9) + 1;
|
|
2346
|
-
let contentBase = state.accumulatedContent + state.finalReply;
|
|
2347
|
-
if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
|
|
2348
|
-
const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
|
|
2349
|
-
if (
|
|
2350
|
-
fullContent === display.lastSentContent
|
|
2351
|
-
&& activityHeaderTitle === display.lastSentHeaderTitle
|
|
2352
|
-
) continue;
|
|
2353
|
-
|
|
2354
|
-
display.lastSentContent = fullContent;
|
|
2355
|
-
display.lastSentHeaderTitle = activityHeaderTitle;
|
|
2356
|
-
const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
|
|
2357
|
-
display.cardBusy = true;
|
|
2358
|
-
const mySeq = display.sequence + 1;
|
|
2359
|
-
try {
|
|
2360
|
-
const card = buildProgressCard(progressView({ text: cardContent, showStop: true, headerTitle: activityHeaderTitle }));
|
|
2361
|
-
await p.cardUpdate(display.cardId, card, mySeq);
|
|
2362
|
-
display.sequence = mySeq;
|
|
2363
|
-
} catch (err) {
|
|
2364
|
-
const errMsg = (err as Error).message;
|
|
2365
|
-
console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${errMsg}`);
|
|
2366
|
-
if (errMsg.includes("300317")) {
|
|
2367
|
-
display.sequence = mySeq;
|
|
2368
|
-
} else if (!display.streamErrorNotified) {
|
|
2369
|
-
display.streamErrorNotified = true;
|
|
2370
|
-
p.sendText(chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
|
|
2371
|
-
}
|
|
2372
|
-
} finally {
|
|
2373
|
-
display.cardBusy = false;
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
}
|
|
2377
|
-
} catch (err) {
|
|
2378
|
-
console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
} finally {
|
|
2382
|
-
tickRunning = false;
|
|
2383
|
-
}
|
|
2384
|
-
})().catch((err: unknown) => {
|
|
2385
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
2386
|
-
console.error(`[${ts()}] Unified display loop uncaught: ${e.message}`);
|
|
2387
|
-
});
|
|
2388
|
-
}, 3000);
|
|
2389
|
-
|
|
2390
|
-
setUnifiedDisplayLoopHandle(interval);
|
|
2391
|
-
console.log(`[${ts()}] [DISPLAY] Unified display loop started`);
|
|
2392
|
-
}
|
|
2393
|
-
|
|
2394
|
-
export function stopUnifiedDisplayLoop(): void {
|
|
2395
|
-
if (unifiedDisplayLoopHandle !== null) {
|
|
2396
|
-
clearInterval(unifiedDisplayLoopHandle);
|
|
2397
|
-
setUnifiedDisplayLoopHandle(null);
|
|
2398
|
-
console.log(`[${ts()}] [DISPLAY] Unified display loop stopped`);
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
// ---------------------------------------------------------------------------
|
|
2403
|
-
// stopSession — 停止指定 session 的活跃 prompt
|
|
2404
|
-
// ---------------------------------------------------------------------------
|
|
2405
|
-
//
|
|
2406
|
-
// 设计要点:
|
|
2407
|
-
// 1) controller.abort() 触发 adapter finally 里的 killProcessTree(proc.pid),
|
|
2408
|
-
// 后者负责把整棵 CLI 进程树(cmd.exe 壳 + node CLI 入口 + 真二进制)一起
|
|
2409
|
-
// 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
|
|
2410
|
-
// 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
|
|
2411
|
-
//
|
|
2412
|
-
// 2) 立刻 fire-and-forget 把 stream-state 标 stopped,不依赖 runAgentSession
|
|
2413
|
-
// 的 finally。原因:generator 自然结束依赖子进程 stdout 关闭,killProcessTree
|
|
2414
|
-
// 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
|
|
2415
|
-
// 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
|
|
2416
|
-
// finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
|
|
2417
|
-
export function stopSession(sessionId: string): boolean {
|
|
2418
|
-
// /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
|
|
2419
|
-
// 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
|
|
2420
|
-
const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
|
|
2421
|
-
const prompt = activePrompts.get(sessionId);
|
|
2422
|
-
if (!prompt) {
|
|
2423
|
-
if (cancelledRecovery) {
|
|
2424
|
-
cancelQueuedMessage(sessionId);
|
|
2425
|
-
console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
|
|
2426
|
-
return true;
|
|
2427
|
-
}
|
|
2428
|
-
return false;
|
|
2429
|
-
}
|
|
2430
|
-
prompt.stopped = true;
|
|
2431
|
-
clearPromptResponseStallMonitor(sessionId);
|
|
2432
|
-
clearPromptProcessMonitor(sessionId);
|
|
2433
|
-
clearPromptAvatarRefreshTimer(sessionId);
|
|
2434
|
-
clearPromptFinalResponseCloseTimer(sessionId);
|
|
2435
|
-
cancelQueuedMessage(sessionId);
|
|
2436
|
-
|
|
2437
|
-
// 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
|
|
2438
|
-
// cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
|
|
2439
|
-
// 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
|
|
2440
|
-
// killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
|
|
2441
|
-
void killProcessTree(prompt.processPid);
|
|
2442
|
-
try {
|
|
2443
|
-
prompt.closeSession?.();
|
|
2444
|
-
} catch (err) {
|
|
2445
|
-
console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
|
|
2446
|
-
}
|
|
2447
|
-
prompt.controller.abort();
|
|
2448
|
-
console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
|
|
2449
|
-
|
|
2450
|
-
// fire-and-forget:立刻把 stream-state.status 改成 stopped,
|
|
2451
|
-
// 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
|
|
2452
|
-
void (async () => {
|
|
2453
|
-
try {
|
|
2454
|
-
const current = await readStreamState(sessionId);
|
|
2455
|
-
if (!current) return;
|
|
2456
|
-
// 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
|
|
2457
|
-
if (current.status !== "running") return;
|
|
2458
|
-
await writeStreamState({
|
|
2459
|
-
...current,
|
|
2460
|
-
status: "stopped",
|
|
2461
|
-
updatedAt: Date.now(),
|
|
2462
|
-
});
|
|
2463
|
-
} catch (err) {
|
|
2464
|
-
console.warn(
|
|
2465
|
-
`[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${(err as Error).message}`,
|
|
2466
|
-
);
|
|
2467
|
-
}
|
|
2468
|
-
})();
|
|
2469
|
-
|
|
2470
|
-
return true;
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
// ---------------------------------------------------------------------------
|
|
2474
|
-
// Session status query (供 /state、/sessions 命令使用)
|
|
2475
|
-
// ---------------------------------------------------------------------------
|
|
2476
|
-
//
|
|
2477
|
-
// model / effort 的来源策略(按 tool 区分,避免硬塞 ChatCCC 全局配置导致显示
|
|
2478
|
-
// 与实际不符):
|
|
2479
|
-
// - tool === "cursor"
|
|
2480
|
-
// model:调用 cursor-adapter.getSessionInfo 取持久化的真实模型,
|
|
2481
|
-
// 未学习到时显示占位符 "—"
|
|
2482
|
-
// effort:cursor-agent 没有 effort 概念,恒为 null(卡片渲染时隐藏该行)
|
|
2483
|
-
// - tool === "claude"(默认)
|
|
2484
|
-
// model:anthropicConfigDisplay(CLAUDE_MODEL)
|
|
2485
|
-
// effort:anthropicConfigDisplay(CLAUDE_EFFORT)
|
|
2486
|
-
// ---------------------------------------------------------------------------
|
|
2487
|
-
|
|
2488
|
-
/** 未知/未学习到时的 model 占位符(卡片可视提示,避免在 UI 上显示空字符串) */
|
|
2489
|
-
export const UNKNOWN_MODEL_PLACEHOLDER = "—";
|
|
2490
|
-
|
|
2491
|
-
export interface SessionStatus {
|
|
2492
|
-
sessionId: string;
|
|
2493
|
-
chatName: string;
|
|
2494
|
-
running: boolean;
|
|
2495
|
-
turnCount: number;
|
|
2496
|
-
lastContextTokens: number;
|
|
2497
|
-
startTime: number;
|
|
2498
|
-
model: string;
|
|
2499
|
-
/** null 表示该工具没有 effort 概念(如 Cursor),调用方应隐藏该行 */
|
|
2500
|
-
effort: string | null;
|
|
2501
|
-
accumulatedLength: number;
|
|
2502
|
-
}
|
|
2503
|
-
|
|
2504
|
-
async function resolveModelEffort(
|
|
2505
|
-
tool: string,
|
|
2506
|
-
sessionId: string,
|
|
2507
|
-
): Promise<{ model: string; effort: string | null }> {
|
|
2508
|
-
if (tool === "cursor") {
|
|
2509
|
-
let model = UNKNOWN_MODEL_PLACEHOLDER;
|
|
2510
|
-
try {
|
|
2511
|
-
const adapter = getAdapterForTool(tool, sessionId);
|
|
2512
|
-
const info = await adapter.getSessionInfo(sessionId);
|
|
2513
|
-
if (info?.model) model = info.model;
|
|
2514
|
-
} catch {
|
|
2515
|
-
// adapter 异常时降级为占位符(不阻塞 /state 卡片)
|
|
2516
|
-
}
|
|
2517
|
-
return { model, effort: null };
|
|
2518
|
-
}
|
|
2519
|
-
if (tool === "codex") {
|
|
2520
|
-
const m = getEffectiveModelForTool(tool, sessionId);
|
|
2521
|
-
const e = getEffectiveEffortForTool(tool, sessionId);
|
|
2522
|
-
return {
|
|
2523
|
-
model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
|
|
2524
|
-
effort: e.trim() !== "" ? e : UNKNOWN_MODEL_PLACEHOLDER,
|
|
2525
|
-
};
|
|
2526
|
-
}
|
|
2527
|
-
if (tool === "ccc") {
|
|
2528
|
-
const m = getEffectiveModelForTool(tool, sessionId);
|
|
2529
|
-
return {
|
|
2530
|
-
model: m.trim() !== "" ? m : UNKNOWN_MODEL_PLACEHOLDER,
|
|
2531
|
-
effort: null,
|
|
2532
|
-
};
|
|
2533
|
-
}
|
|
2534
|
-
return {
|
|
2535
|
-
model: anthropicConfigDisplay(getModelForSession(sessionId)),
|
|
2536
|
-
effort: anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId)),
|
|
2537
|
-
};
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
export async function getSessionStatus(chatId: string): Promise<SessionStatus | null> {
|
|
2541
|
-
const info = sessionInfoMap.get(chatId);
|
|
2542
|
-
if (!info) return null;
|
|
2543
|
-
|
|
2544
|
-
const activePrompt = activePrompts.get(info.sessionId);
|
|
2545
|
-
const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
|
|
2546
|
-
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
2547
|
-
|
|
2548
|
-
const registry = await loadSessionRegistry();
|
|
2549
|
-
const chatName = registry[chatId]?.chatName ?? "";
|
|
2550
|
-
|
|
2551
|
-
// 从 stream-state.json 获取当前累积长度
|
|
2552
|
-
let accumulatedLength = 0;
|
|
2553
|
-
const streamState = await readStreamState(info.sessionId);
|
|
2554
|
-
if (streamState) {
|
|
2555
|
-
accumulatedLength = streamState.accumulatedContent.length + streamState.finalReply.length;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
return {
|
|
2559
|
-
sessionId: info.sessionId,
|
|
2560
|
-
chatName,
|
|
2561
|
-
running: isActive,
|
|
2562
|
-
turnCount: info.turnCount,
|
|
2563
|
-
lastContextTokens: info.lastContextTokens,
|
|
2564
|
-
startTime: info.startTime,
|
|
2565
|
-
model,
|
|
2566
|
-
effort,
|
|
2567
|
-
accumulatedLength,
|
|
2568
|
-
};
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
export interface SessionsListEntry {
|
|
2572
|
-
chatId: string;
|
|
2573
|
-
chatType?: string;
|
|
2574
|
-
sessionId: string;
|
|
2575
|
-
chatName: string;
|
|
2576
|
-
active: boolean;
|
|
2577
|
-
turnCount: number;
|
|
2578
|
-
startTime: number;
|
|
2579
|
-
model: string;
|
|
2580
|
-
/** null 表示该工具没有 effort 概念(如 Cursor) */
|
|
2581
|
-
effort: string | null;
|
|
2582
|
-
tool: string;
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
2586
|
-
const registry = await loadSessionRegistry();
|
|
2587
|
-
const registryEntries = Object.values(registry)
|
|
2588
|
-
.filter((record) => record.chatId && record.sessionId && record.tool)
|
|
2589
|
-
.map((record) => ({ ...record, sortTime: record.updatedAt }));
|
|
2590
|
-
const registeredSessionIds = new Set(registryEntries.map((record) => record.sessionId));
|
|
2591
|
-
const sessionTools = await loadSessionTools();
|
|
2592
|
-
const orphanEntries = Object.entries(sessionTools)
|
|
2593
|
-
.filter(([sessionId, record]) => sessionId && record?.tool && !registeredSessionIds.has(sessionId))
|
|
2594
|
-
.map(([sessionId, record]) => {
|
|
2595
|
-
const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
|
|
2596
|
-
const active = activePrompts.get(sessionId);
|
|
2597
|
-
return {
|
|
2598
|
-
chatId: "",
|
|
2599
|
-
chatType: undefined,
|
|
2600
|
-
sessionId,
|
|
2601
|
-
tool: record.tool,
|
|
2602
|
-
chatName: record.chatName ?? "",
|
|
2603
|
-
turnCount: 0,
|
|
2604
|
-
lastContextTokens: 0,
|
|
2605
|
-
startTime: active?.startTime ?? createdAt,
|
|
2606
|
-
updatedAt: createdAt,
|
|
2607
|
-
running: false,
|
|
2608
|
-
sortTime: active?.startTime ?? createdAt,
|
|
2609
|
-
};
|
|
2610
|
-
});
|
|
2611
|
-
const entries = [...registryEntries, ...orphanEntries]
|
|
2612
|
-
.sort((a, b) => b.sortTime - a.sortTime)
|
|
2613
|
-
.slice(0, 20);
|
|
2614
|
-
// 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
|
|
2615
|
-
return Promise.all(
|
|
2616
|
-
entries.map(async (info) => {
|
|
2617
|
-
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
2618
|
-
return {
|
|
2619
|
-
chatId: info.chatId,
|
|
2620
|
-
chatType: info.chatType,
|
|
2621
|
-
sessionId: info.sessionId,
|
|
2622
|
-
chatName: info.chatName || "",
|
|
2623
|
-
active: !!activePrompts.get(info.sessionId) &&
|
|
2624
|
-
!activePrompts.get(info.sessionId)?.stopped &&
|
|
2625
|
-
!activePrompts.get(info.sessionId)?.abnormalExit,
|
|
2626
|
-
turnCount: info.turnCount,
|
|
2627
|
-
startTime: info.startTime,
|
|
2628
|
-
model,
|
|
2629
|
-
effort,
|
|
2630
|
-
tool: info.tool,
|
|
2631
|
-
};
|
|
2632
|
-
}),
|
|
2633
|
-
);
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
// ---------------------------------------------------------------------------
|
|
2637
|
-
// 测试辅助:注入自定义 adapter 到 adapterCache
|
|
2638
|
-
// ---------------------------------------------------------------------------
|
|
2639
|
-
// 仅供单测使用——下划线前缀表明非生产 API。让 session-status 的测试可以
|
|
2640
|
-
// 注入一个内存 store + adapter,以验证 cursor 分支按 tool 取真实 model。
|
|
2641
|
-
// ---------------------------------------------------------------------------
|
|
2642
|
-
|
|
2643
|
-
export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
|
|
2644
|
-
adapterCache.set(tool, adapter);
|
|
2645
|
-
// 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
|
|
2646
|
-
const effective = getEffectiveModelForTool(tool);
|
|
2647
|
-
const effort = getEffectiveEffortForTool(tool);
|
|
2648
|
-
const fastMode = getEffectiveFastModeForTool(tool);
|
|
2649
|
-
adapterCache.set(`${tool}:${effective || ""}:${effort || ""}:${fastMode ? "fast" : "default"}`, adapter);
|
|
2650
|
-
if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
|
|
2651
|
-
}
|
|
2652
|
-
|
|
2653
|
-
export function clearAdapterCache(): void {
|
|
2654
|
-
adapterCache.clear();
|
|
2655
|
-
}
|
|
2656
|
-
|
|
2657
|
-
export function _clearAdapterCacheForTest(): void {
|
|
2658
|
-
clearAdapterCache();
|
|
2659
|
-
}
|