opencode-bridge 2.9.0-beta

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.
Files changed (237) hide show
  1. package/.env.example +131 -0
  2. package/LICENSE +674 -0
  3. package/README.md +1195 -0
  4. package/bin/opencode-bridge.js +31 -0
  5. package/dist/commands/effort.d.ts +9 -0
  6. package/dist/commands/effort.d.ts.map +1 -0
  7. package/dist/commands/effort.js +56 -0
  8. package/dist/commands/effort.js.map +1 -0
  9. package/dist/commands/parser.d.ts +37 -0
  10. package/dist/commands/parser.d.ts.map +1 -0
  11. package/dist/commands/parser.js +355 -0
  12. package/dist/commands/parser.js.map +1 -0
  13. package/dist/config.d.ts +91 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +340 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/feishu/cards-stream.d.ts +65 -0
  18. package/dist/feishu/cards-stream.d.ts.map +1 -0
  19. package/dist/feishu/cards-stream.js +448 -0
  20. package/dist/feishu/cards-stream.js.map +1 -0
  21. package/dist/feishu/cards.d.ts +81 -0
  22. package/dist/feishu/cards.d.ts.map +1 -0
  23. package/dist/feishu/cards.js +560 -0
  24. package/dist/feishu/cards.js.map +1 -0
  25. package/dist/feishu/client.d.ts +132 -0
  26. package/dist/feishu/client.d.ts.map +1 -0
  27. package/dist/feishu/client.js +952 -0
  28. package/dist/feishu/client.js.map +1 -0
  29. package/dist/feishu/streamer.d.ts +30 -0
  30. package/dist/feishu/streamer.d.ts.map +1 -0
  31. package/dist/feishu/streamer.js +95 -0
  32. package/dist/feishu/streamer.js.map +1 -0
  33. package/dist/handlers/card-action.d.ts +12 -0
  34. package/dist/handlers/card-action.d.ts.map +1 -0
  35. package/dist/handlers/card-action.js +154 -0
  36. package/dist/handlers/card-action.js.map +1 -0
  37. package/dist/handlers/command.d.ts +76 -0
  38. package/dist/handlers/command.d.ts.map +1 -0
  39. package/dist/handlers/command.js +1773 -0
  40. package/dist/handlers/command.js.map +1 -0
  41. package/dist/handlers/discord.d.ts +78 -0
  42. package/dist/handlers/discord.d.ts.map +1 -0
  43. package/dist/handlers/discord.js +1832 -0
  44. package/dist/handlers/discord.js.map +1 -0
  45. package/dist/handlers/file-sender.d.ts +22 -0
  46. package/dist/handlers/file-sender.d.ts.map +1 -0
  47. package/dist/handlers/file-sender.js +183 -0
  48. package/dist/handlers/file-sender.js.map +1 -0
  49. package/dist/handlers/group.d.ts +21 -0
  50. package/dist/handlers/group.d.ts.map +1 -0
  51. package/dist/handlers/group.js +414 -0
  52. package/dist/handlers/group.js.map +1 -0
  53. package/dist/handlers/lifecycle.d.ts +17 -0
  54. package/dist/handlers/lifecycle.d.ts.map +1 -0
  55. package/dist/handlers/lifecycle.js +129 -0
  56. package/dist/handlers/lifecycle.js.map +1 -0
  57. package/dist/handlers/p2p.d.ts +44 -0
  58. package/dist/handlers/p2p.d.ts.map +1 -0
  59. package/dist/handlers/p2p.js +625 -0
  60. package/dist/handlers/p2p.js.map +1 -0
  61. package/dist/index.d.ts +33 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +1562 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/opencode/client.d.ts +176 -0
  66. package/dist/opencode/client.d.ts.map +1 -0
  67. package/dist/opencode/client.js +1126 -0
  68. package/dist/opencode/client.js.map +1 -0
  69. package/dist/opencode/delayed-handler.d.ts +33 -0
  70. package/dist/opencode/delayed-handler.d.ts.map +1 -0
  71. package/dist/opencode/delayed-handler.js +74 -0
  72. package/dist/opencode/delayed-handler.js.map +1 -0
  73. package/dist/opencode/output-buffer.d.ts +56 -0
  74. package/dist/opencode/output-buffer.d.ts.map +1 -0
  75. package/dist/opencode/output-buffer.js +202 -0
  76. package/dist/opencode/output-buffer.js.map +1 -0
  77. package/dist/opencode/question-handler.d.ts +61 -0
  78. package/dist/opencode/question-handler.d.ts.map +1 -0
  79. package/dist/opencode/question-handler.js +183 -0
  80. package/dist/opencode/question-handler.js.map +1 -0
  81. package/dist/opencode/question-parser.d.ts +9 -0
  82. package/dist/opencode/question-parser.d.ts.map +1 -0
  83. package/dist/opencode/question-parser.js +69 -0
  84. package/dist/opencode/question-parser.js.map +1 -0
  85. package/dist/opencode/session-queue.d.ts +16 -0
  86. package/dist/opencode/session-queue.d.ts.map +1 -0
  87. package/dist/opencode/session-queue.js +41 -0
  88. package/dist/opencode/session-queue.js.map +1 -0
  89. package/dist/permissions/handler.d.ts +36 -0
  90. package/dist/permissions/handler.d.ts.map +1 -0
  91. package/dist/permissions/handler.js +141 -0
  92. package/dist/permissions/handler.js.map +1 -0
  93. package/dist/platform/adapters/discord-adapter.d.ts +45 -0
  94. package/dist/platform/adapters/discord-adapter.d.ts.map +1 -0
  95. package/dist/platform/adapters/discord-adapter.js +497 -0
  96. package/dist/platform/adapters/discord-adapter.js.map +1 -0
  97. package/dist/platform/adapters/feishu-adapter.d.ts +29 -0
  98. package/dist/platform/adapters/feishu-adapter.d.ts.map +1 -0
  99. package/dist/platform/adapters/feishu-adapter.js +150 -0
  100. package/dist/platform/adapters/feishu-adapter.js.map +1 -0
  101. package/dist/platform/registry.d.ts +41 -0
  102. package/dist/platform/registry.d.ts.map +1 -0
  103. package/dist/platform/registry.js +87 -0
  104. package/dist/platform/registry.js.map +1 -0
  105. package/dist/platform/types.d.ts +92 -0
  106. package/dist/platform/types.d.ts.map +1 -0
  107. package/dist/platform/types.js +4 -0
  108. package/dist/platform/types.js.map +1 -0
  109. package/dist/reliability/audit-log.d.ts +93 -0
  110. package/dist/reliability/audit-log.d.ts.map +1 -0
  111. package/dist/reliability/audit-log.js +248 -0
  112. package/dist/reliability/audit-log.js.map +1 -0
  113. package/dist/reliability/config-guard.d.ts +42 -0
  114. package/dist/reliability/config-guard.d.ts.map +1 -0
  115. package/dist/reliability/config-guard.js +264 -0
  116. package/dist/reliability/config-guard.js.map +1 -0
  117. package/dist/reliability/conversation-heartbeat.d.ts +37 -0
  118. package/dist/reliability/conversation-heartbeat.d.ts.map +1 -0
  119. package/dist/reliability/conversation-heartbeat.js +179 -0
  120. package/dist/reliability/conversation-heartbeat.js.map +1 -0
  121. package/dist/reliability/cron-api-server.d.ts +13 -0
  122. package/dist/reliability/cron-api-server.d.ts.map +1 -0
  123. package/dist/reliability/cron-api-server.js +247 -0
  124. package/dist/reliability/cron-api-server.js.map +1 -0
  125. package/dist/reliability/cron-control.d.ts +34 -0
  126. package/dist/reliability/cron-control.d.ts.map +1 -0
  127. package/dist/reliability/cron-control.js +864 -0
  128. package/dist/reliability/cron-control.js.map +1 -0
  129. package/dist/reliability/cron-semantic.d.ts +9 -0
  130. package/dist/reliability/cron-semantic.d.ts.map +1 -0
  131. package/dist/reliability/cron-semantic.js +208 -0
  132. package/dist/reliability/cron-semantic.js.map +1 -0
  133. package/dist/reliability/environment-doctor.d.ts +56 -0
  134. package/dist/reliability/environment-doctor.d.ts.map +1 -0
  135. package/dist/reliability/environment-doctor.js +213 -0
  136. package/dist/reliability/environment-doctor.js.map +1 -0
  137. package/dist/reliability/job-registry.d.ts +26 -0
  138. package/dist/reliability/job-registry.d.ts.map +1 -0
  139. package/dist/reliability/job-registry.js +77 -0
  140. package/dist/reliability/job-registry.js.map +1 -0
  141. package/dist/reliability/opencode-probe.d.ts +37 -0
  142. package/dist/reliability/opencode-probe.d.ts.map +1 -0
  143. package/dist/reliability/opencode-probe.js +195 -0
  144. package/dist/reliability/opencode-probe.js.map +1 -0
  145. package/dist/reliability/opencode-restart.d.ts +42 -0
  146. package/dist/reliability/opencode-restart.d.ts.map +1 -0
  147. package/dist/reliability/opencode-restart.js +155 -0
  148. package/dist/reliability/opencode-restart.js.map +1 -0
  149. package/dist/reliability/proactive-heartbeat.d.ts +39 -0
  150. package/dist/reliability/proactive-heartbeat.d.ts.map +1 -0
  151. package/dist/reliability/proactive-heartbeat.js +147 -0
  152. package/dist/reliability/proactive-heartbeat.js.map +1 -0
  153. package/dist/reliability/process-check-job.d.ts +73 -0
  154. package/dist/reliability/process-check-job.d.ts.map +1 -0
  155. package/dist/reliability/process-check-job.js +254 -0
  156. package/dist/reliability/process-check-job.js.map +1 -0
  157. package/dist/reliability/process-guard.d.ts +53 -0
  158. package/dist/reliability/process-guard.d.ts.map +1 -0
  159. package/dist/reliability/process-guard.js +344 -0
  160. package/dist/reliability/process-guard.js.map +1 -0
  161. package/dist/reliability/recovery-reporter.d.ts +37 -0
  162. package/dist/reliability/recovery-reporter.d.ts.map +1 -0
  163. package/dist/reliability/recovery-reporter.js +161 -0
  164. package/dist/reliability/recovery-reporter.js.map +1 -0
  165. package/dist/reliability/rescue-executor.d.ts +52 -0
  166. package/dist/reliability/rescue-executor.d.ts.map +1 -0
  167. package/dist/reliability/rescue-executor.js +244 -0
  168. package/dist/reliability/rescue-executor.js.map +1 -0
  169. package/dist/reliability/rescue-policy.d.ts +39 -0
  170. package/dist/reliability/rescue-policy.d.ts.map +1 -0
  171. package/dist/reliability/rescue-policy.js +85 -0
  172. package/dist/reliability/rescue-policy.js.map +1 -0
  173. package/dist/reliability/runtime-cron-dispatcher.d.ts +30 -0
  174. package/dist/reliability/runtime-cron-dispatcher.d.ts.map +1 -0
  175. package/dist/reliability/runtime-cron-dispatcher.js +100 -0
  176. package/dist/reliability/runtime-cron-dispatcher.js.map +1 -0
  177. package/dist/reliability/runtime-cron-orphan.d.ts +18 -0
  178. package/dist/reliability/runtime-cron-orphan.d.ts.map +1 -0
  179. package/dist/reliability/runtime-cron-orphan.js +87 -0
  180. package/dist/reliability/runtime-cron-orphan.js.map +1 -0
  181. package/dist/reliability/runtime-cron-registry.d.ts +4 -0
  182. package/dist/reliability/runtime-cron-registry.d.ts.map +1 -0
  183. package/dist/reliability/runtime-cron-registry.js +8 -0
  184. package/dist/reliability/runtime-cron-registry.js.map +1 -0
  185. package/dist/reliability/runtime-cron.d.ts +75 -0
  186. package/dist/reliability/runtime-cron.d.ts.map +1 -0
  187. package/dist/reliability/runtime-cron.js +309 -0
  188. package/dist/reliability/runtime-cron.js.map +1 -0
  189. package/dist/reliability/scheduler.d.ts +38 -0
  190. package/dist/reliability/scheduler.d.ts.map +1 -0
  191. package/dist/reliability/scheduler.js +174 -0
  192. package/dist/reliability/scheduler.js.map +1 -0
  193. package/dist/reliability/types.d.ts +151 -0
  194. package/dist/reliability/types.d.ts.map +1 -0
  195. package/dist/reliability/types.js +178 -0
  196. package/dist/reliability/types.js.map +1 -0
  197. package/dist/router/action-handlers.d.ts +27 -0
  198. package/dist/router/action-handlers.d.ts.map +1 -0
  199. package/dist/router/action-handlers.js +226 -0
  200. package/dist/router/action-handlers.js.map +1 -0
  201. package/dist/router/opencode-event-hub.d.ts +159 -0
  202. package/dist/router/opencode-event-hub.d.ts.map +1 -0
  203. package/dist/router/opencode-event-hub.js +589 -0
  204. package/dist/router/opencode-event-hub.js.map +1 -0
  205. package/dist/router/root-router.d.ts +94 -0
  206. package/dist/router/root-router.d.ts.map +1 -0
  207. package/dist/router/root-router.js +214 -0
  208. package/dist/router/root-router.js.map +1 -0
  209. package/dist/store/chat-session.d.ts +150 -0
  210. package/dist/store/chat-session.d.ts.map +1 -0
  211. package/dist/store/chat-session.js +640 -0
  212. package/dist/store/chat-session.js.map +1 -0
  213. package/dist/store/session-directory.d.ts +12 -0
  214. package/dist/store/session-directory.d.ts.map +1 -0
  215. package/dist/store/session-directory.js +47 -0
  216. package/dist/store/session-directory.js.map +1 -0
  217. package/dist/store/session-group.d.ts +19 -0
  218. package/dist/store/session-group.d.ts.map +1 -0
  219. package/dist/store/session-group.js +92 -0
  220. package/dist/store/session-group.js.map +1 -0
  221. package/dist/store/user-session.d.ts +19 -0
  222. package/dist/store/user-session.d.ts.map +1 -0
  223. package/dist/store/user-session.js +112 -0
  224. package/dist/store/user-session.js.map +1 -0
  225. package/dist/utils/async-queue.d.ts +12 -0
  226. package/dist/utils/async-queue.d.ts.map +1 -0
  227. package/dist/utils/async-queue.js +51 -0
  228. package/dist/utils/async-queue.js.map +1 -0
  229. package/dist/utils/directory-policy.d.ts +50 -0
  230. package/dist/utils/directory-policy.d.ts.map +1 -0
  231. package/dist/utils/directory-policy.js +379 -0
  232. package/dist/utils/directory-policy.js.map +1 -0
  233. package/dist/utils/session-title.d.ts +2 -0
  234. package/dist/utils/session-title.d.ts.map +1 -0
  235. package/dist/utils/session-title.js +10 -0
  236. package/dist/utils/session-title.js.map +1 -0
  237. package/package.json +73 -0
package/dist/index.js ADDED
@@ -0,0 +1,1562 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { feishuClient } from './feishu/client.js';
3
+ import { feishuAdapter } from './platform/adapters/feishu-adapter.js';
4
+ import { discordAdapter } from './platform/adapters/discord-adapter.js';
5
+ import { opencodeClient } from './opencode/client.js';
6
+ import { outputBuffer } from './opencode/output-buffer.js';
7
+ import { delayedResponseHandler } from './opencode/delayed-handler.js';
8
+ import { questionHandler } from './opencode/question-handler.js';
9
+ import { permissionHandler } from './permissions/handler.js';
10
+ import { chatSessionStore } from './store/chat-session.js';
11
+ import { lifecycleHandler } from './handlers/lifecycle.js';
12
+ import { createDiscordHandler } from './handlers/discord.js';
13
+ import { commandHandler } from './handlers/command.js';
14
+ import { validateConfig, routerConfig, outputConfig, reliabilityConfig, opencodeConfig } from './config.js';
15
+ import { rootRouter } from './router/root-router.js';
16
+ import { ConversationHeartbeatEngine } from './reliability/conversation-heartbeat.js';
17
+ import { CronScheduler } from './reliability/scheduler.js';
18
+ import { createInternalJobRegistry } from './reliability/job-registry.js';
19
+ import { createProcessCheckJobRunner, createRepairBudgetState } from './reliability/process-check-job.js';
20
+ import { createProactiveHeartbeatRunner } from './reliability/proactive-heartbeat.js';
21
+ import { RuntimeCronManager } from './reliability/runtime-cron.js';
22
+ import { createCronApiServer } from './reliability/cron-api-server.js';
23
+ import { getRuntimeCronManager, setRuntimeCronManager } from './reliability/runtime-cron-registry.js';
24
+ import { createRuntimeCronDispatcher } from './reliability/runtime-cron-dispatcher.js';
25
+ import { cleanupRuntimeCronJobsByConversation, scanAndCleanupOrphanRuntimeCronJobs } from './reliability/runtime-cron-orphan.js';
26
+ import { probeOpenCodeHealth } from './reliability/opencode-probe.js';
27
+ import { decideRescuePolicy } from './reliability/rescue-policy.js';
28
+ import { executeRescuePipeline } from './reliability/rescue-executor.js';
29
+ import { reportRecoveryContext } from './reliability/recovery-reporter.js';
30
+ import { FailureType, RescueState } from './reliability/types.js';
31
+ import { createPermissionActionCallbacks, createQuestionActionCallbacks, } from './router/action-handlers.js';
32
+ import { openCodeEventHub } from './router/opencode-event-hub.js';
33
+ import { buildStreamCards, } from './feishu/cards-stream.js';
34
+ export const createRescueOrchestrator = (logger = console) => {
35
+ let rescueState = RescueState.HEALTHY;
36
+ let failureCount = 0;
37
+ let firstFailureAtMs = 0;
38
+ let repairBudgetRemaining = reliabilityConfig.repairBudget;
39
+ let lastRepairAtMs;
40
+ const HEALTHY_LOG_INTERVAL_MS = 10 * 60 * 1000;
41
+ const WAIT_LOG_INTERVAL_MS = 5 * 60 * 1000;
42
+ let lastHealthyLogAtMs = 0;
43
+ let lastPolicyLogAtMs = 0;
44
+ let lastPolicySignature = '';
45
+ return {
46
+ runWatchdogProbe: async () => {
47
+ const nowMs = Date.now();
48
+ try {
49
+ const probeResult = await probeOpenCodeHealth({
50
+ host: opencodeConfig.host,
51
+ port: opencodeConfig.port,
52
+ });
53
+ if (probeResult.ok) {
54
+ failureCount = 0;
55
+ firstFailureAtMs = 0;
56
+ rescueState = RescueState.HEALTHY;
57
+ const shouldLogHealthy = lastHealthyLogAtMs === 0 || nowMs - lastHealthyLogAtMs >= HEALTHY_LOG_INTERVAL_MS;
58
+ if (shouldLogHealthy) {
59
+ logger.info('[Reliability] watchdog probe healthy');
60
+ lastHealthyLogAtMs = nowMs;
61
+ }
62
+ return;
63
+ }
64
+ failureCount += 1;
65
+ if (firstFailureAtMs === 0) {
66
+ firstFailureAtMs = nowMs;
67
+ }
68
+ const failureType = probeResult.failureType ?? FailureType.OPENCODE_HTTP_DOWN;
69
+ const policyDecision = decideRescuePolicy({
70
+ failureType,
71
+ currentState: rescueState,
72
+ latestAttemptFailed: true,
73
+ nowMs,
74
+ retry: {
75
+ mode: 'infinite',
76
+ attempt: failureCount,
77
+ failureCount,
78
+ firstFailureAtMs,
79
+ },
80
+ rescue: {
81
+ targetHost: opencodeConfig.host,
82
+ budgetRemaining: repairBudgetRemaining,
83
+ lastRepairAtMs,
84
+ },
85
+ });
86
+ rescueState = policyDecision.nextState;
87
+ repairBudgetRemaining = policyDecision.nextBudgetRemaining;
88
+ if (policyDecision.action !== 'repair') {
89
+ const signature = `${policyDecision.action}:${policyDecision.reason}`;
90
+ const shouldLogPolicy = signature !== lastPolicySignature
91
+ || lastPolicyLogAtMs === 0
92
+ || nowMs - lastPolicyLogAtMs >= WAIT_LOG_INTERVAL_MS;
93
+ if (shouldLogPolicy) {
94
+ logger.info(`[Reliability] watchdog policy=${policyDecision.action} reason=${policyDecision.reason}`);
95
+ lastPolicyLogAtMs = nowMs;
96
+ lastPolicySignature = signature;
97
+ }
98
+ return;
99
+ }
100
+ logger.info(`[Reliability] watchdog rescue start reason=${policyDecision.reason}`);
101
+ const startOpenCode = async () => {
102
+ return new Promise((resolve, reject) => {
103
+ try {
104
+ const child = spawn('opencode', [], {
105
+ detached: true,
106
+ stdio: 'ignore',
107
+ });
108
+ child.unref();
109
+ setTimeout(() => resolve(), 2000);
110
+ }
111
+ catch (error) {
112
+ reject(error);
113
+ }
114
+ });
115
+ };
116
+ const rescueResult = await executeRescuePipeline({
117
+ lockTargetPath: './logs/opencode-rescue',
118
+ pidFilePath: './logs/opencode.pid',
119
+ host: opencodeConfig.host,
120
+ port: opencodeConfig.port,
121
+ configPath: process.env.OPENCODE_CONFIG_FILE?.trim() || './opencode.json',
122
+ serverFields: {
123
+ host: opencodeConfig.host,
124
+ port: opencodeConfig.port,
125
+ auth: {
126
+ username: opencodeConfig.serverUsername,
127
+ password: opencodeConfig.serverPassword,
128
+ },
129
+ },
130
+ startOpenCode,
131
+ });
132
+ if (!rescueResult.ok) {
133
+ rescueState = RescueState.DEGRADED;
134
+ logger.error(`[Reliability] rescue failed step=${rescueResult.failedStep} reason=${rescueResult.reason}`);
135
+ return;
136
+ }
137
+ lastRepairAtMs = Date.now();
138
+ rescueState = RescueState.RECOVERED;
139
+ await reportRecoveryContext({
140
+ failureType,
141
+ failureReason: policyDecision.reason,
142
+ backupPath: rescueResult.config.backup.path,
143
+ nextActions: [
144
+ '检查 OpenCode 健康端点与认证配置是否长期稳定',
145
+ '观察下一轮 watchdog 探针,确认故障不再复现',
146
+ ],
147
+ selfCheckCommands: [
148
+ 'npm run build',
149
+ 'npm test -- tests/reliability-bootstrap.test.ts',
150
+ ],
151
+ context: {
152
+ policyAction: policyDecision.action,
153
+ policyReason: policyDecision.reason,
154
+ trace: rescueResult.trace,
155
+ health: rescueResult.health,
156
+ },
157
+ });
158
+ logger.info('[Reliability] rescue succeeded and recovery context reported');
159
+ }
160
+ catch (error) {
161
+ logger.error('[Reliability] watchdog probe failed:', error);
162
+ }
163
+ },
164
+ runStaleCleanup: async () => {
165
+ logger.info('[Reliability] stale cleanup tick');
166
+ },
167
+ runBudgetReset: async () => {
168
+ logger.info('[Reliability] budget reset tick');
169
+ },
170
+ cleanup: async () => {
171
+ logger.info('[Reliability] rescue orchestrator cleaned');
172
+ },
173
+ };
174
+ };
175
+ export const bootstrapReliabilityLifecycle = (dependencies = {}) => {
176
+ const logger = dependencies.logger ?? console;
177
+ const shouldUseInboundHeartbeat = reliabilityConfig.inboundHeartbeatEnabled || Boolean(dependencies.createHeartbeatEngine);
178
+ const heartbeatEngine = dependencies.createHeartbeatEngine?.()
179
+ ?? new ConversationHeartbeatEngine({
180
+ windowMs: reliabilityConfig.heartbeatIntervalMs,
181
+ });
182
+ const scheduler = dependencies.createScheduler?.() ?? new CronScheduler();
183
+ const rescueOrchestrator = dependencies.createRescueOrchestrator?.() ?? createRescueOrchestrator(logger);
184
+ // 初始化 process check job runner
185
+ const repairBudgetState = createRepairBudgetState(reliabilityConfig.repairBudget);
186
+ const processCheckRunner = createProcessCheckJobRunner({
187
+ bridgePidFilePath: './logs/bridge.pid',
188
+ opencodePidFilePath: './logs/opencode.pid',
189
+ opencodeHost: 'localhost',
190
+ opencodePort: 4096,
191
+ repairBudgetState,
192
+ staleLockPaths: [],
193
+ });
194
+ const jobHandlers = {
195
+ watchdogProbe: async () => {
196
+ await rescueOrchestrator.runWatchdogProbe();
197
+ },
198
+ processConsistencyCheck: async () => {
199
+ await processCheckRunner.checkProcessConsistency();
200
+ },
201
+ staleCleanup: async () => {
202
+ await rescueOrchestrator.runStaleCleanup();
203
+ await cleanupOrphanCronJobs();
204
+ },
205
+ budgetReset: async () => {
206
+ await processCheckRunner.resetBudget();
207
+ },
208
+ };
209
+ const registry = dependencies.createJobRegistry?.(jobHandlers)
210
+ ?? {
211
+ registerAll: (injectedScheduler) => {
212
+ if (!(injectedScheduler instanceof CronScheduler)) {
213
+ throw new Error('[Reliability] 默认任务注册器要求 CronScheduler 实例');
214
+ }
215
+ createInternalJobRegistry({
216
+ handlers: jobHandlers,
217
+ }).registerAll(injectedScheduler);
218
+ },
219
+ };
220
+ registry.registerAll(scheduler);
221
+ let runtimeCronManager = null;
222
+ const runtimeCronDispatcher = createRuntimeCronDispatcher({
223
+ getSessionById: async (sessionId, options) => {
224
+ return await opencodeClient.getSessionById(sessionId, options);
225
+ },
226
+ sendMessage: async (sessionId, text, options) => {
227
+ return await opencodeClient.sendMessage(sessionId, text, options);
228
+ },
229
+ sendMessageAsync: async (sessionId, text, options) => {
230
+ await opencodeClient.sendMessageAsync(sessionId, text, options);
231
+ return true;
232
+ },
233
+ getSender: platform => {
234
+ if (platform === 'feishu') {
235
+ return feishuAdapter.getSender();
236
+ }
237
+ if (platform === 'discord') {
238
+ return discordAdapter.getSender();
239
+ }
240
+ return null;
241
+ },
242
+ logger: {
243
+ info: message => {
244
+ logger.info(message);
245
+ },
246
+ warn: message => {
247
+ logger.info(message);
248
+ },
249
+ error: (...args) => {
250
+ logger.error('[RuntimeCronDispatch]', ...args);
251
+ },
252
+ },
253
+ });
254
+ if (scheduler instanceof CronScheduler) {
255
+ runtimeCronManager = new RuntimeCronManager({
256
+ scheduler,
257
+ filePath: reliabilityConfig.cronJobsFile,
258
+ dispatchPayload: async (job) => {
259
+ await runtimeCronDispatcher.dispatch(job);
260
+ },
261
+ logger: {
262
+ info: message => {
263
+ logger.info(message);
264
+ },
265
+ warn: message => {
266
+ logger.info(message);
267
+ },
268
+ error: (...args) => {
269
+ logger.error('[RuntimeCron]', ...args);
270
+ },
271
+ },
272
+ });
273
+ setRuntimeCronManager(runtimeCronManager);
274
+ }
275
+ else {
276
+ logger.info('[Reliability] 当前 scheduler 非 CronScheduler,跳过 runtime cron manager 注入');
277
+ setRuntimeCronManager(null);
278
+ }
279
+ const cleanupOrphanCronJobs = async () => {
280
+ if (!runtimeCronManager || !reliabilityConfig.cronOrphanAutoCleanup) {
281
+ return;
282
+ }
283
+ const cleanup = await scanAndCleanupOrphanRuntimeCronJobs(runtimeCronManager, {
284
+ hasConversationBinding: (platform, conversationId, sessionId) => {
285
+ const binding = chatSessionStore.getSessionByConversation(platform, conversationId);
286
+ if (!binding) {
287
+ return false;
288
+ }
289
+ return !sessionId || binding.sessionId === sessionId;
290
+ },
291
+ getSessionStatus: async (sessionId, directory) => {
292
+ try {
293
+ const session = await opencodeClient.getSessionById(sessionId, directory ? { directory } : undefined);
294
+ return session ? 'exists' : 'missing';
295
+ }
296
+ catch {
297
+ return 'unknown';
298
+ }
299
+ },
300
+ });
301
+ if (cleanup.removedJobIds.length > 0) {
302
+ logger.info(`[RuntimeCron] orphan cleanup removed ${cleanup.removedJobIds.length} job(s)`);
303
+ }
304
+ };
305
+ const proactiveHeartbeatRunner = createProactiveHeartbeatRunner({
306
+ enabled: reliabilityConfig.proactiveHeartbeatEnabled,
307
+ intervalMs: reliabilityConfig.heartbeatIntervalMs,
308
+ prompt: reliabilityConfig.heartbeatPrompt || '',
309
+ agent: reliabilityConfig.heartbeatAgent,
310
+ client: {
311
+ createSession: async (title, directory) => {
312
+ const created = await opencodeClient.createSession(title, directory);
313
+ return { id: created.id };
314
+ },
315
+ getSessionById: async (sessionId, options) => {
316
+ return await opencodeClient.getSessionById(sessionId, options);
317
+ },
318
+ sendMessage: async (sessionId, text, options) => {
319
+ const response = await opencodeClient.sendMessage(sessionId, text, options);
320
+ return { parts: response.parts };
321
+ },
322
+ },
323
+ notifyAlert: async (alertText) => {
324
+ if (reliabilityConfig.heartbeatAlertChats.length === 0) {
325
+ return;
326
+ }
327
+ const sender = feishuAdapter.getSender();
328
+ for (const chatId of reliabilityConfig.heartbeatAlertChats) {
329
+ try {
330
+ await sender.sendText(chatId, `⚠️ [Heartbeat Alert]\n${alertText}`);
331
+ }
332
+ catch (error) {
333
+ logger.error(`[Heartbeat] 发送告警失败 chat=${chatId}:`, error);
334
+ }
335
+ }
336
+ },
337
+ logger: {
338
+ info: message => {
339
+ logger.info(message);
340
+ },
341
+ warn: message => {
342
+ logger.info(message);
343
+ },
344
+ error: (...args) => {
345
+ logger.error(...args);
346
+ },
347
+ },
348
+ });
349
+ let cronApiServer = null;
350
+ if (reliabilityConfig.cronApiEnabled && runtimeCronManager) {
351
+ cronApiServer = createCronApiServer(runtimeCronManager, {
352
+ host: reliabilityConfig.cronApiHost,
353
+ port: reliabilityConfig.cronApiPort,
354
+ token: reliabilityConfig.cronApiToken,
355
+ logger: {
356
+ info: message => {
357
+ logger.info(message);
358
+ },
359
+ warn: message => {
360
+ logger.info(message);
361
+ },
362
+ error: (...args) => {
363
+ logger.error(...args);
364
+ },
365
+ },
366
+ });
367
+ void cronApiServer.start().catch(error => {
368
+ logger.error('[RuntimeCronAPI] 启动失败:', error);
369
+ });
370
+ }
371
+ if (reliabilityConfig.cronEnabled) {
372
+ void cleanupOrphanCronJobs().catch(error => {
373
+ logger.error('[RuntimeCron] startup orphan cleanup failed:', error);
374
+ });
375
+ scheduler.start();
376
+ }
377
+ proactiveHeartbeatRunner.start();
378
+ logger.info('[Reliability] bootstrap 完成(heartbeat + scheduler + rescue orchestrator)');
379
+ let cleaned = false;
380
+ return {
381
+ onInboundMessage: async () => {
382
+ if (!shouldUseInboundHeartbeat) {
383
+ return;
384
+ }
385
+ try {
386
+ await heartbeatEngine.onInboundMessage();
387
+ }
388
+ catch (error) {
389
+ logger.error('[Heartbeat] 入站触发执行失败:', error);
390
+ }
391
+ },
392
+ cleanup: async () => {
393
+ if (cleaned) {
394
+ return;
395
+ }
396
+ cleaned = true;
397
+ await Promise.all([
398
+ scheduler.stop(),
399
+ Promise.resolve(rescueOrchestrator.cleanup()),
400
+ Promise.resolve(proactiveHeartbeatRunner.stop()),
401
+ cronApiServer ? cronApiServer.stop() : Promise.resolve(),
402
+ ]);
403
+ setRuntimeCronManager(null);
404
+ logger.info('[Reliability] cleanup 完成');
405
+ },
406
+ };
407
+ };
408
+ async function main() {
409
+ console.log('╔════════════════════════════════════════════════╗');
410
+ console.log('║ 飞书 × OpenCode 桥接服务 v2.9.0-beta (Group) ║');
411
+ console.log('╚════════════════════════════════════════════════╝');
412
+ // 1. 验证配置
413
+ try {
414
+ validateConfig();
415
+ }
416
+ catch (error) {
417
+ console.error('配置错误:', error);
418
+ process.exit(1);
419
+ }
420
+ // 1.5. 路由器模式配置
421
+ console.log(`[Config] 路由器模式: ${routerConfig.mode}`);
422
+ if (routerConfig.enabledPlatforms.length > 0) {
423
+ console.log(`[Config] 启用的平台: ${routerConfig.enabledPlatforms.join(', ')}`);
424
+ }
425
+ else {
426
+ console.log(`[Config] 平台过滤: 未指定(所有平台可用)`);
427
+ }
428
+ if (routerConfig.mode === 'dual') {
429
+ console.log(`[Config] ⚠️ 双轨模式: 将记录新旧路由对比日志,不改变当前行为`);
430
+ console.log(`[Config] 📝 如需回滚到旧版路由,设置 ROUTER_MODE=legacy 并重启服务`);
431
+ }
432
+ // 2. 连接 OpenCode
433
+ const connected = await opencodeClient.connect();
434
+ if (!connected) {
435
+ console.error('无法连接到OpenCode服务器,请确保 opencode serve 已运行');
436
+ process.exit(1);
437
+ }
438
+ // 3. 配置输出缓冲 (流式响应)
439
+ const streamContentMap = new Map();
440
+ const reasoningSnapshotMap = new Map();
441
+ const textSnapshotMap = new Map();
442
+ const retryNoticeMap = new Map();
443
+ const errorNoticeMap = new Map();
444
+ const streamCardMessageIdsMap = new Map();
445
+ const STREAM_CARD_COMPONENT_BUDGET = 180;
446
+ const CORRELATION_CACHE_TTL_MS = 10 * 60 * 1000;
447
+ const toolCallChatMap = new Map();
448
+ const messageChatMap = new Map();
449
+ const streamToolStateMap = new Map();
450
+ const streamTimelineMap = new Map();
451
+ const getPendingPermissionForChat = (chatId) => {
452
+ const head = permissionHandler.peekForChat(chatId);
453
+ if (!head)
454
+ return undefined;
455
+ const pendingCount = permissionHandler.getQueueSizeForChat(chatId);
456
+ return {
457
+ sessionId: head.sessionId,
458
+ permissionId: head.permissionId,
459
+ tool: head.tool,
460
+ description: head.description,
461
+ risk: head.risk,
462
+ pendingCount,
463
+ };
464
+ };
465
+ const getOrCreateTimelineState = (bufferKey) => {
466
+ let timeline = streamTimelineMap.get(bufferKey);
467
+ if (!timeline) {
468
+ timeline = {
469
+ order: [],
470
+ segments: new Map(),
471
+ };
472
+ streamTimelineMap.set(bufferKey, timeline);
473
+ }
474
+ return timeline;
475
+ };
476
+ const trimTimeline = (timeline) => {
477
+ const limit = 80;
478
+ while (timeline.order.length > limit) {
479
+ const removedKey = timeline.order.shift();
480
+ if (removedKey) {
481
+ timeline.segments.delete(removedKey);
482
+ }
483
+ }
484
+ };
485
+ const upsertTimelineSegment = (bufferKey, segmentKey, segment) => {
486
+ const timeline = getOrCreateTimelineState(bufferKey);
487
+ if (!timeline.segments.has(segmentKey)) {
488
+ timeline.order.push(segmentKey);
489
+ trimTimeline(timeline);
490
+ }
491
+ timeline.segments.set(segmentKey, segment);
492
+ };
493
+ const appendTimelineText = (bufferKey, segmentKey, type, deltaText) => {
494
+ if (!deltaText)
495
+ return;
496
+ const timeline = getOrCreateTimelineState(bufferKey);
497
+ const previous = timeline.segments.get(segmentKey);
498
+ if (previous && previous.type === type) {
499
+ timeline.segments.set(segmentKey, {
500
+ type,
501
+ text: `${previous.text}${deltaText}`,
502
+ });
503
+ return;
504
+ }
505
+ if (!timeline.segments.has(segmentKey)) {
506
+ timeline.order.push(segmentKey);
507
+ trimTimeline(timeline);
508
+ }
509
+ timeline.segments.set(segmentKey, {
510
+ type,
511
+ text: deltaText,
512
+ });
513
+ };
514
+ const setTimelineText = (bufferKey, segmentKey, type, text) => {
515
+ const timeline = getOrCreateTimelineState(bufferKey);
516
+ const previous = timeline.segments.get(segmentKey);
517
+ if (previous && previous.type === type && previous.text === text) {
518
+ return;
519
+ }
520
+ if (!timeline.segments.has(segmentKey)) {
521
+ timeline.order.push(segmentKey);
522
+ trimTimeline(timeline);
523
+ }
524
+ timeline.segments.set(segmentKey, { type, text });
525
+ };
526
+ const upsertTimelineTool = (bufferKey, toolKey, state, kind = 'tool') => {
527
+ const segmentKey = `tool:${toolKey}`;
528
+ const timeline = getOrCreateTimelineState(bufferKey);
529
+ const previous = timeline.segments.get(segmentKey);
530
+ if (previous && previous.type === 'tool') {
531
+ timeline.segments.set(segmentKey, {
532
+ type: 'tool',
533
+ name: state.name,
534
+ status: state.status,
535
+ output: state.output ?? previous.output,
536
+ kind,
537
+ });
538
+ return;
539
+ }
540
+ if (!timeline.segments.has(segmentKey)) {
541
+ timeline.order.push(segmentKey);
542
+ trimTimeline(timeline);
543
+ }
544
+ timeline.segments.set(segmentKey, {
545
+ type: 'tool',
546
+ name: state.name,
547
+ status: state.status,
548
+ ...(state.output !== undefined ? { output: state.output } : {}),
549
+ kind,
550
+ });
551
+ };
552
+ const upsertTimelineNote = (bufferKey, noteKey, text, variant) => {
553
+ upsertTimelineSegment(bufferKey, `note:${noteKey}`, {
554
+ type: 'note',
555
+ text,
556
+ ...(variant ? { variant } : {}),
557
+ });
558
+ };
559
+ // 注入动作处理回调到 RootRouter
560
+ rootRouter.setPermissionCallbacks(createPermissionActionCallbacks(upsertTimelineNote));
561
+ rootRouter.setQuestionCallbacks(createQuestionActionCallbacks());
562
+ const discordHandler = createDiscordHandler(discordAdapter.getSender());
563
+ const getTimelineSegments = (bufferKey) => {
564
+ const timeline = streamTimelineMap.get(bufferKey);
565
+ if (!timeline) {
566
+ return [];
567
+ }
568
+ const segments = [];
569
+ for (const key of timeline.order) {
570
+ const segment = timeline.segments.get(key);
571
+ if (!segment)
572
+ continue;
573
+ if (segment.type === 'text' || segment.type === 'reasoning') {
574
+ if (!segment.text.trim())
575
+ continue;
576
+ segments.push({
577
+ type: segment.type,
578
+ text: segment.text,
579
+ });
580
+ continue;
581
+ }
582
+ if (segment.type === 'tool') {
583
+ segments.push({
584
+ type: 'tool',
585
+ name: segment.name,
586
+ status: segment.status,
587
+ ...(segment.output !== undefined ? { output: segment.output } : {}),
588
+ ...(segment.kind ? { kind: segment.kind } : {}),
589
+ });
590
+ continue;
591
+ }
592
+ if (!segment.text.trim())
593
+ continue;
594
+ segments.push({
595
+ type: 'note',
596
+ text: segment.text,
597
+ ...(segment.variant ? { variant: segment.variant } : {}),
598
+ });
599
+ }
600
+ return segments;
601
+ };
602
+ const getPendingQuestionForBuffer = (sessionId, chatId) => {
603
+ const pending = questionHandler.getBySession(sessionId);
604
+ if (!pending || pending.chatId !== chatId) {
605
+ return undefined;
606
+ }
607
+ const totalQuestions = pending.request.questions.length;
608
+ if (totalQuestions === 0) {
609
+ return undefined;
610
+ }
611
+ const safeIndex = Math.min(Math.max(pending.currentQuestionIndex, 0), totalQuestions - 1);
612
+ const question = pending.request.questions[safeIndex];
613
+ if (!question) {
614
+ return undefined;
615
+ }
616
+ return {
617
+ requestId: pending.request.id,
618
+ sessionId: pending.request.sessionID,
619
+ chatId: pending.chatId,
620
+ questionIndex: safeIndex,
621
+ totalQuestions,
622
+ header: typeof question.header === 'string' ? question.header : '',
623
+ question: typeof question.question === 'string' ? question.question : '',
624
+ options: Array.isArray(question.options)
625
+ ? question.options.map(option => ({
626
+ label: typeof option.label === 'string' ? option.label : '',
627
+ description: typeof option.description === 'string' ? option.description : '',
628
+ }))
629
+ : [],
630
+ multiple: question.multiple === true,
631
+ };
632
+ };
633
+ const toSessionId = (value) => {
634
+ return typeof value === 'string' ? value : '';
635
+ };
636
+ const toNonEmptyString = (value) => {
637
+ if (typeof value !== 'string') {
638
+ return undefined;
639
+ }
640
+ const normalized = value.trim();
641
+ return normalized.length > 0 ? normalized : undefined;
642
+ };
643
+ const setCorrelationChatRef = (map, key, chatId) => {
644
+ const normalizedKey = toNonEmptyString(key);
645
+ const normalizedChatId = toNonEmptyString(chatId);
646
+ if (!normalizedKey || !normalizedChatId) {
647
+ return;
648
+ }
649
+ map.set(normalizedKey, {
650
+ chatId: normalizedChatId,
651
+ expiresAt: Date.now() + CORRELATION_CACHE_TTL_MS,
652
+ });
653
+ };
654
+ const getCorrelationChatRef = (map, key) => {
655
+ const normalizedKey = toNonEmptyString(key);
656
+ if (!normalizedKey) {
657
+ return undefined;
658
+ }
659
+ const entry = map.get(normalizedKey);
660
+ if (!entry) {
661
+ return undefined;
662
+ }
663
+ if (entry.expiresAt <= Date.now()) {
664
+ map.delete(normalizedKey);
665
+ return undefined;
666
+ }
667
+ if (!chatSessionStore.hasConversationId(entry.chatId)) {
668
+ map.delete(normalizedKey);
669
+ return undefined;
670
+ }
671
+ return entry.chatId;
672
+ };
673
+ const resolvePermissionChat = (event) => {
674
+ const directChatId = chatSessionStore.getChatId(event.sessionId);
675
+ if (directChatId) {
676
+ return { chatId: directChatId, source: 'session' };
677
+ }
678
+ const parentSessionId = toNonEmptyString(event.parentSessionId);
679
+ if (parentSessionId) {
680
+ const parentChatId = chatSessionStore.getChatId(parentSessionId);
681
+ if (parentChatId) {
682
+ return { chatId: parentChatId, source: 'parent_session' };
683
+ }
684
+ }
685
+ const relatedSessionId = toNonEmptyString(event.relatedSessionId);
686
+ if (relatedSessionId) {
687
+ const relatedChatId = chatSessionStore.getChatId(relatedSessionId);
688
+ if (relatedChatId) {
689
+ return { chatId: relatedChatId, source: 'related_session' };
690
+ }
691
+ }
692
+ const toolCallChatId = getCorrelationChatRef(toolCallChatMap, event.callId);
693
+ if (toolCallChatId) {
694
+ return { chatId: toolCallChatId, source: 'tool_call' };
695
+ }
696
+ const messageChatId = getCorrelationChatRef(messageChatMap, event.messageId);
697
+ if (messageChatId) {
698
+ return { chatId: messageChatId, source: 'message' };
699
+ }
700
+ return { source: 'unresolved' };
701
+ };
702
+ const resolveSessionConversation = (sessionId) => {
703
+ const conversation = chatSessionStore.getConversationBySessionId(sessionId);
704
+ if (conversation) {
705
+ return {
706
+ platform: conversation.platform,
707
+ conversationId: conversation.conversationId,
708
+ };
709
+ }
710
+ const feishuChatId = chatSessionStore.getChatId(sessionId);
711
+ if (feishuChatId) {
712
+ return {
713
+ platform: 'feishu',
714
+ conversationId: feishuChatId,
715
+ };
716
+ }
717
+ return null;
718
+ };
719
+ const buildBufferKeyBySession = (sessionId, conversationId) => {
720
+ const conversation = resolveSessionConversation(sessionId);
721
+ const platform = conversation?.platform ?? 'feishu';
722
+ const resolvedConversationId = conversation?.conversationId ?? conversationId;
723
+ if (platform === 'feishu') {
724
+ return `chat:${resolvedConversationId}`;
725
+ }
726
+ return `chat:${platform}:${resolvedConversationId}`;
727
+ };
728
+ const buildPermissionQueueKeyBySession = (sessionId, conversationId) => {
729
+ const conversation = resolveSessionConversation(sessionId);
730
+ const platform = conversation?.platform ?? 'feishu';
731
+ const resolvedConversationId = conversation?.conversationId ?? conversationId;
732
+ if (platform === 'feishu') {
733
+ return resolvedConversationId;
734
+ }
735
+ return `${platform}:${resolvedConversationId}`;
736
+ };
737
+ const buildPortableUpdateText = (data, showThinking = true) => {
738
+ const mainText = data.text.trim();
739
+ const thinkingText = showThinking ? data.thinking.trim() : '';
740
+ if (mainText && thinkingText) {
741
+ const safeThinking = thinkingText.replace(/```/g, '` ` `');
742
+ const clippedThinking = safeThinking.length > 1400
743
+ ? `${safeThinking.slice(0, 1400)}\n...(思考内容已截断)`
744
+ : safeThinking;
745
+ return [
746
+ '-----------',
747
+ '```md',
748
+ clippedThinking,
749
+ '```',
750
+ '-----------',
751
+ mainText,
752
+ ].join('\n');
753
+ }
754
+ if (mainText) {
755
+ return `-----------\n${mainText}`;
756
+ }
757
+ if (thinkingText) {
758
+ const safeThinking = thinkingText.replace(/```/g, '` ` `');
759
+ const clippedThinking = safeThinking.length > 1400
760
+ ? `${safeThinking.slice(0, 1400)}\n...(思考内容已截断)`
761
+ : safeThinking;
762
+ return [
763
+ '-----------',
764
+ '```md',
765
+ clippedThinking,
766
+ '```',
767
+ '-----------',
768
+ '⏳ 正在处理...',
769
+ ].join('\n');
770
+ }
771
+ if (data.status === 'failed') {
772
+ return '❌ 执行失败';
773
+ }
774
+ if (data.status === 'completed') {
775
+ return '✅ 已完成';
776
+ }
777
+ return '⏳ 正在处理...';
778
+ };
779
+ const buildPortableUpdatePayload = (data, conversationId, platform = 'feishu') => {
780
+ // 根据平台读取可见性配置
781
+ const showThinkingChain = platform === 'discord'
782
+ ? outputConfig.discord.showThinkingChain
783
+ : platform === 'feishu'
784
+ ? outputConfig.feishu.showThinkingChain
785
+ : outputConfig.showThinkingChain;
786
+ const showToolChain = platform === 'discord'
787
+ ? outputConfig.discord.showToolChain
788
+ : platform === 'feishu'
789
+ ? outputConfig.feishu.showToolChain
790
+ : outputConfig.showToolChain;
791
+ // 过滤 segments,移除 tool 和 reasoning 类型(当对应开关关闭时)
792
+ const filteredSegments = showToolChain && showThinkingChain
793
+ ? data.segments
794
+ : (data.segments ?? []).filter(segment => {
795
+ if (!showToolChain && segment.type === 'tool')
796
+ return false;
797
+ if (!showThinkingChain && segment.type === 'reasoning')
798
+ return false;
799
+ return true;
800
+ });
801
+ const filteredData = {
802
+ ...data,
803
+ segments: filteredSegments,
804
+ };
805
+ const baseText = buildPortableUpdateText(filteredData, showThinkingChain);
806
+ if (!data.pendingQuestion) {
807
+ return { discordText: baseText };
808
+ }
809
+ const questionLine = `❓ ${data.pendingQuestion.question}`;
810
+ const progressLine = `第 ${data.pendingQuestion.questionIndex + 1}/${data.pendingQuestion.totalQuestions} 题`;
811
+ const discordText = `${baseText}\n${questionLine}\n${progressLine}`;
812
+ const optionList = data.pendingQuestion.options
813
+ .filter(option => option.label.trim().length > 0)
814
+ .slice(0, 24)
815
+ .map(option => ({
816
+ label: option.label,
817
+ value: option.label,
818
+ ...(option.description ? { description: option.description } : {}),
819
+ }));
820
+ const options = [...optionList, {
821
+ label: '跳过本题',
822
+ value: '__skip__',
823
+ description: '留空并进入下一题',
824
+ }];
825
+ if (options.length === 0) {
826
+ return { discordText };
827
+ }
828
+ const maxValues = data.pendingQuestion.multiple
829
+ ? Math.min(Math.max(1, optionList.length), 25)
830
+ : 1;
831
+ return {
832
+ discordText,
833
+ discordComponents: [
834
+ {
835
+ type: 'select',
836
+ customId: `oc_question:${conversationId}`,
837
+ placeholder: '选择当前问题答案',
838
+ options,
839
+ minValues: 1,
840
+ maxValues,
841
+ },
842
+ ],
843
+ };
844
+ };
845
+ const normalizeToolStatus = (status) => {
846
+ if (status === 'pending' || status === 'running' || status === 'completed') {
847
+ return status;
848
+ }
849
+ if (status === 'error' || status === 'failed') {
850
+ return 'failed';
851
+ }
852
+ return 'running';
853
+ };
854
+ const getToolStatusText = (status) => {
855
+ if (status === 'pending')
856
+ return '等待中';
857
+ if (status === 'running')
858
+ return '执行中';
859
+ if (status === 'completed')
860
+ return '已完成';
861
+ return '失败';
862
+ };
863
+ const stringifyToolOutput = (value) => {
864
+ if (value === undefined || value === null)
865
+ return undefined;
866
+ if (typeof value === 'string')
867
+ return value;
868
+ try {
869
+ return JSON.stringify(value, null, 2);
870
+ }
871
+ catch {
872
+ return String(value);
873
+ }
874
+ };
875
+ const asRecord = (value) => {
876
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
877
+ return null;
878
+ }
879
+ return value;
880
+ };
881
+ const pickFirstDefined = (...values) => {
882
+ for (const value of values) {
883
+ if (value !== undefined && value !== null) {
884
+ return value;
885
+ }
886
+ }
887
+ return undefined;
888
+ };
889
+ const buildToolTraceOutput = (part, status, withInput) => {
890
+ const state = asRecord(part.state);
891
+ const inputValue = withInput
892
+ ? pickFirstDefined(part.input, part.args, part.arguments, state?.input, state?.args, state?.arguments)
893
+ : undefined;
894
+ const outputValue = status === 'failed'
895
+ ? pickFirstDefined(state?.error, state?.output, part.error)
896
+ : pickFirstDefined(state?.output, state?.result, state?.message, part.output, part.result);
897
+ const inputText = stringifyToolOutput(inputValue);
898
+ const outputText = stringifyToolOutput(outputValue);
899
+ const blocks = [];
900
+ if (inputText && inputText.trim()) {
901
+ blocks.push(`调用参数:\n${inputText.trim()}`);
902
+ }
903
+ if (outputText && outputText.trim()) {
904
+ blocks.push(`${status === 'failed' ? '错误输出' : '执行输出'}:\n${outputText.trim()}`);
905
+ }
906
+ if (blocks.length === 0) {
907
+ return `状态更新:${getToolStatusText(status)}`;
908
+ }
909
+ return blocks.join('\n\n');
910
+ };
911
+ const TOOL_TRACE_LIMIT = 20000;
912
+ const clipToolTrace = (text) => {
913
+ if (text.length <= TOOL_TRACE_LIMIT) {
914
+ return text;
915
+ }
916
+ const retained = text.slice(-TOOL_TRACE_LIMIT);
917
+ return `...(历史输出过长,已截断前 ${text.length - TOOL_TRACE_LIMIT} 字)...\n${retained}`;
918
+ };
919
+ const mergeToolOutput = (previous, incoming) => {
920
+ if (!incoming || !incoming.trim()) {
921
+ return previous;
922
+ }
923
+ const next = incoming.trim();
924
+ if (!previous || !previous.trim()) {
925
+ return clipToolTrace(next);
926
+ }
927
+ const prev = previous.trim();
928
+ if (prev === next) {
929
+ return previous;
930
+ }
931
+ if (next.startsWith(prev) || next.includes(prev)) {
932
+ return clipToolTrace(next);
933
+ }
934
+ if (prev.startsWith(next) || prev.includes(next)) {
935
+ return previous;
936
+ }
937
+ return clipToolTrace(`${previous}\n\n---\n${next}`);
938
+ };
939
+ const getOrCreateToolStateBucket = (bufferKey) => {
940
+ let bucket = streamToolStateMap.get(bufferKey);
941
+ if (!bucket) {
942
+ bucket = new Map();
943
+ streamToolStateMap.set(bufferKey, bucket);
944
+ }
945
+ return bucket;
946
+ };
947
+ const syncToolsToBuffer = (bufferKey) => {
948
+ const bucket = streamToolStateMap.get(bufferKey);
949
+ if (!bucket) {
950
+ outputBuffer.setTools(bufferKey, []);
951
+ return;
952
+ }
953
+ outputBuffer.setTools(bufferKey, Array.from(bucket.values()).map(item => ({
954
+ name: item.name,
955
+ status: item.status,
956
+ ...(item.output !== undefined ? { output: item.output } : {}),
957
+ })));
958
+ };
959
+ const upsertToolState = (bufferKey, toolKey, nextState, kind = 'tool') => {
960
+ const bucket = getOrCreateToolStateBucket(bufferKey);
961
+ const previous = bucket.get(toolKey);
962
+ const mergedOutput = mergeToolOutput(previous?.output, nextState.output);
963
+ bucket.set(toolKey, {
964
+ name: nextState.name,
965
+ status: nextState.status,
966
+ output: mergedOutput,
967
+ kind: nextState.kind ?? previous?.kind ?? kind,
968
+ });
969
+ upsertTimelineTool(bufferKey, toolKey, {
970
+ name: nextState.name,
971
+ status: nextState.status,
972
+ output: mergedOutput,
973
+ kind: nextState.kind ?? previous?.kind ?? kind,
974
+ }, nextState.kind ?? previous?.kind ?? kind);
975
+ syncToolsToBuffer(bufferKey);
976
+ };
977
+ const markActiveToolsCompleted = (bufferKey) => {
978
+ const bucket = streamToolStateMap.get(bufferKey);
979
+ if (!bucket)
980
+ return;
981
+ for (const [toolKey, item] of bucket.entries()) {
982
+ if (item.status === 'running' || item.status === 'pending') {
983
+ bucket.set(toolKey, {
984
+ ...item,
985
+ status: 'completed',
986
+ });
987
+ upsertTimelineTool(bufferKey, toolKey, {
988
+ ...item,
989
+ status: 'completed',
990
+ }, item.kind ?? 'tool');
991
+ }
992
+ }
993
+ syncToolsToBuffer(bufferKey);
994
+ };
995
+ const appendTextFromPart = (sessionID, part, bufferKey) => {
996
+ if (typeof part.text !== 'string')
997
+ return;
998
+ if (typeof part.id !== 'string' || !part.id) {
999
+ outputBuffer.append(bufferKey, part.text);
1000
+ appendTimelineText(bufferKey, `text:${sessionID}:anonymous`, 'text', part.text);
1001
+ return;
1002
+ }
1003
+ const key = `${sessionID}:${part.id}`;
1004
+ const prev = textSnapshotMap.get(key) || '';
1005
+ const current = part.text;
1006
+ if (current.startsWith(prev)) {
1007
+ const deltaText = current.slice(prev.length);
1008
+ if (deltaText) {
1009
+ outputBuffer.append(bufferKey, deltaText);
1010
+ }
1011
+ }
1012
+ else if (current !== prev) {
1013
+ outputBuffer.append(bufferKey, current);
1014
+ }
1015
+ textSnapshotMap.set(key, current);
1016
+ setTimelineText(bufferKey, `text:${key}`, 'text', current);
1017
+ };
1018
+ const appendReasoningFromPart = (sessionID, part, bufferKey) => {
1019
+ if (typeof part.text !== 'string')
1020
+ return;
1021
+ if (typeof part.id !== 'string' || !part.id) {
1022
+ outputBuffer.appendThinking(bufferKey, part.text);
1023
+ appendTimelineText(bufferKey, `reasoning:${sessionID}:anonymous`, 'reasoning', part.text);
1024
+ return;
1025
+ }
1026
+ const key = `${sessionID}:${part.id}`;
1027
+ const prev = reasoningSnapshotMap.get(key) || '';
1028
+ const current = part.text;
1029
+ if (current.startsWith(prev)) {
1030
+ const deltaText = current.slice(prev.length);
1031
+ if (deltaText) {
1032
+ outputBuffer.appendThinking(bufferKey, deltaText);
1033
+ }
1034
+ }
1035
+ else if (current !== prev) {
1036
+ outputBuffer.appendThinking(bufferKey, current);
1037
+ }
1038
+ reasoningSnapshotMap.set(key, current);
1039
+ setTimelineText(bufferKey, `reasoning:${key}`, 'reasoning', current);
1040
+ };
1041
+ const clearPartSnapshotsForSession = (sessionID) => {
1042
+ const prefix = `${sessionID}:`;
1043
+ for (const key of reasoningSnapshotMap.keys()) {
1044
+ if (key.startsWith(prefix)) {
1045
+ reasoningSnapshotMap.delete(key);
1046
+ }
1047
+ }
1048
+ for (const key of textSnapshotMap.keys()) {
1049
+ if (key.startsWith(prefix)) {
1050
+ textSnapshotMap.delete(key);
1051
+ }
1052
+ }
1053
+ retryNoticeMap.delete(sessionID);
1054
+ errorNoticeMap.delete(sessionID);
1055
+ };
1056
+ const formatProviderError = (raw) => {
1057
+ if (!raw || typeof raw !== 'object') {
1058
+ return '模型执行失败';
1059
+ }
1060
+ const error = raw;
1061
+ const name = typeof error.name === 'string' ? error.name : 'UnknownError';
1062
+ const data = error.data && typeof error.data === 'object' ? error.data : {};
1063
+ if (name === 'APIError') {
1064
+ const message = typeof data.message === 'string' ? data.message : '上游接口报错';
1065
+ const statusCode = typeof data.statusCode === 'number' ? data.statusCode : undefined;
1066
+ if (statusCode === 429) {
1067
+ return `模型请求过快(429):${message}`;
1068
+ }
1069
+ if (statusCode === 408 || statusCode === 504) {
1070
+ return `模型响应超时:${message}`;
1071
+ }
1072
+ return statusCode ? `模型接口错误(${statusCode}):${message}` : `模型接口错误:${message}`;
1073
+ }
1074
+ if (name === 'ProviderAuthError') {
1075
+ const providerID = typeof data.providerID === 'string' ? data.providerID : 'unknown';
1076
+ const message = typeof data.message === 'string' ? data.message : '鉴权失败';
1077
+ return `模型鉴权失败(${providerID}):${message}`;
1078
+ }
1079
+ if (name === 'MessageOutputLengthError') {
1080
+ return '模型输出超过长度限制,已中断';
1081
+ }
1082
+ if (name === 'MessageAbortedError') {
1083
+ const message = typeof data.message === 'string' ? data.message : '会话已中断';
1084
+ return `会话已中断:${message}`;
1085
+ }
1086
+ const generic = typeof data.message === 'string' ? data.message : '';
1087
+ return generic ? `${name}:${generic}` : `${name}`;
1088
+ };
1089
+ const upsertLiveCardInteraction = (chatId, replyMessageId, cardData, bodyMessageIds, thinkingMessageId, openCodeMsgId) => {
1090
+ const botMessageIds = [...bodyMessageIds, thinkingMessageId].filter((id) => typeof id === 'string' && id.length > 0);
1091
+ if (botMessageIds.length === 0) {
1092
+ return;
1093
+ }
1094
+ let existing;
1095
+ for (const msgId of botMessageIds) {
1096
+ existing = chatSessionStore.findInteractionByBotMsgId(chatId, msgId);
1097
+ if (existing) {
1098
+ break;
1099
+ }
1100
+ }
1101
+ if (existing) {
1102
+ chatSessionStore.updateInteraction(chatId, r => r === existing, r => {
1103
+ if (!r.userFeishuMsgId && replyMessageId) {
1104
+ r.userFeishuMsgId = replyMessageId;
1105
+ }
1106
+ for (const msgId of botMessageIds) {
1107
+ if (!r.botFeishuMsgIds.includes(msgId)) {
1108
+ r.botFeishuMsgIds.push(msgId);
1109
+ }
1110
+ }
1111
+ r.cardData = { ...cardData };
1112
+ r.type = 'normal';
1113
+ if (openCodeMsgId) {
1114
+ r.openCodeMsgId = openCodeMsgId;
1115
+ }
1116
+ r.timestamp = Date.now();
1117
+ });
1118
+ return;
1119
+ }
1120
+ chatSessionStore.addInteraction(chatId, {
1121
+ userFeishuMsgId: replyMessageId || '',
1122
+ openCodeMsgId: openCodeMsgId || '',
1123
+ botFeishuMsgIds: botMessageIds,
1124
+ type: 'normal',
1125
+ cardData: { ...cardData },
1126
+ timestamp: Date.now(),
1127
+ });
1128
+ };
1129
+ const parsePermissionDecision = (raw) => {
1130
+ const normalized = raw.normalize('NFKC').trim().toLowerCase();
1131
+ if (!normalized)
1132
+ return null;
1133
+ const compact = normalized
1134
+ .replace(/[\s\u3000]+/g, '')
1135
+ .replace(/[。!!,.,;;::\-]/g, '');
1136
+ const hasAlways = compact.includes('始终') ||
1137
+ compact.includes('永久') ||
1138
+ compact.includes('always') ||
1139
+ compact.includes('记住') ||
1140
+ compact.includes('总是');
1141
+ const containsAny = (words) => {
1142
+ return words.some(word => compact === word || compact.includes(word));
1143
+ };
1144
+ const isDeny = compact === 'n' ||
1145
+ compact === 'no' ||
1146
+ compact === '否' ||
1147
+ compact === '拒绝' ||
1148
+ containsAny(['拒绝', '不同意', '不允许', 'deny']);
1149
+ if (isDeny) {
1150
+ return { allow: false, remember: false };
1151
+ }
1152
+ const isAllow = compact === 'y' ||
1153
+ compact === 'yes' ||
1154
+ compact === 'ok' ||
1155
+ compact === 'always' ||
1156
+ compact === '允许' ||
1157
+ compact === '始终允许' ||
1158
+ containsAny(['允许', '同意', '通过', '批准', 'allow']);
1159
+ if (isAllow) {
1160
+ return { allow: true, remember: hasAlways };
1161
+ }
1162
+ return null;
1163
+ };
1164
+ const tryHandlePendingPermissionByText = async (event) => {
1165
+ if (event.chatType !== 'group') {
1166
+ return false;
1167
+ }
1168
+ const trimmedContent = event.content.trim();
1169
+ if (!trimmedContent || trimmedContent.startsWith('/')) {
1170
+ return false;
1171
+ }
1172
+ const pending = permissionHandler.peekForChat(event.chatId);
1173
+ if (!pending) {
1174
+ return false;
1175
+ }
1176
+ const decision = parsePermissionDecision(trimmedContent);
1177
+ if (!decision) {
1178
+ await feishuClient.reply(event.messageId, '当前有待确认权限,请回复:允许 / 拒绝 / 始终允许(也支持 y / n / always)');
1179
+ return true;
1180
+ }
1181
+ const responded = await opencodeClient.respondToPermission(pending.sessionId, pending.permissionId, decision.allow, decision.remember);
1182
+ if (!responded) {
1183
+ console.error(`[权限] 文本响应失败: chat=${event.chatId}, session=${pending.sessionId}, permission=${pending.permissionId}`);
1184
+ await feishuClient.reply(event.messageId, '权限响应失败,请重试');
1185
+ return true;
1186
+ }
1187
+ const removed = permissionHandler.resolveForChat(event.chatId, pending.permissionId);
1188
+ const bufferKey = `chat:${event.chatId}`;
1189
+ if (!outputBuffer.get(bufferKey)) {
1190
+ outputBuffer.getOrCreate(bufferKey, event.chatId, pending.sessionId, event.messageId);
1191
+ }
1192
+ const toolName = removed?.tool || pending.tool || '工具';
1193
+ const resolvedText = decision.allow
1194
+ ? decision.remember
1195
+ ? `✅ 已允许并记住权限:${toolName}`
1196
+ : `✅ 已允许权限:${toolName}`
1197
+ : `❌ 已拒绝权限:${toolName}`;
1198
+ upsertTimelineNote(bufferKey, `permission-result-text:${pending.sessionId}:${pending.permissionId}:${decision.allow ? 'allow' : 'deny'}:${decision.remember ? 'always' : 'once'}`, resolvedText, 'permission');
1199
+ outputBuffer.touch(bufferKey);
1200
+ await feishuClient.reply(event.messageId, decision.allow ? (decision.remember ? '已允许并记住该权限' : '已允许该权限') : '已拒绝该权限');
1201
+ return true;
1202
+ };
1203
+ outputBuffer.setUpdateCallback(async (buffer) => {
1204
+ const { text, thinking } = outputBuffer.getAndClear(buffer.key);
1205
+ const timelineSegments = getTimelineSegments(buffer.key);
1206
+ const sessionConversation = resolveSessionConversation(buffer.sessionId);
1207
+ const platform = sessionConversation?.platform ?? 'feishu';
1208
+ const conversationId = sessionConversation?.conversationId ?? buffer.chatId;
1209
+ const permissionQueueKey = buildPermissionQueueKeyBySession(buffer.sessionId, conversationId);
1210
+ const pendingPermission = getPendingPermissionForChat(permissionQueueKey);
1211
+ const pendingQuestion = getPendingQuestionForBuffer(buffer.sessionId, conversationId);
1212
+ if (!text &&
1213
+ !thinking &&
1214
+ timelineSegments.length === 0 &&
1215
+ buffer.tools.length === 0 &&
1216
+ !pendingPermission &&
1217
+ !pendingQuestion &&
1218
+ buffer.status === 'running')
1219
+ return;
1220
+ const current = streamContentMap.get(buffer.key) || { text: '', thinking: '' };
1221
+ current.text += text;
1222
+ current.thinking += thinking;
1223
+ if (buffer.status !== 'running') {
1224
+ if (buffer.finalText) {
1225
+ current.text = buffer.finalText;
1226
+ }
1227
+ if (buffer.finalThinking) {
1228
+ current.thinking = buffer.finalThinking;
1229
+ }
1230
+ }
1231
+ streamContentMap.set(buffer.key, current);
1232
+ const hasVisibleContent = current.text.trim().length > 0 ||
1233
+ current.thinking.trim().length > 0 ||
1234
+ buffer.tools.length > 0 ||
1235
+ timelineSegments.length > 0 ||
1236
+ Boolean(pendingPermission) ||
1237
+ Boolean(pendingQuestion);
1238
+ if (!hasVisibleContent && buffer.status === 'running')
1239
+ return;
1240
+ const status = buffer.status === 'failed' || buffer.status === 'aborted'
1241
+ ? 'failed'
1242
+ : buffer.status === 'completed'
1243
+ ? 'completed'
1244
+ : 'processing';
1245
+ let existingMessageIds = streamCardMessageIdsMap.get(buffer.key) || [];
1246
+ if (existingMessageIds.length === 0 && buffer.messageId) {
1247
+ existingMessageIds = [buffer.messageId];
1248
+ }
1249
+ const cardData = {
1250
+ text: current.text,
1251
+ thinking: current.thinking,
1252
+ chatId: conversationId,
1253
+ messageId: existingMessageIds[0] || undefined,
1254
+ tools: [...buffer.tools],
1255
+ segments: timelineSegments,
1256
+ ...(pendingPermission ? { pendingPermission } : {}),
1257
+ ...(pendingQuestion ? { pendingQuestion } : {}),
1258
+ status,
1259
+ showThinking: false,
1260
+ };
1261
+ if (platform !== 'feishu') {
1262
+ const sender = platform === 'discord' ? discordAdapter.getSender() : feishuAdapter.getSender();
1263
+ const payload = buildPortableUpdatePayload(cardData, conversationId, platform);
1264
+ const nextMessageIds = [];
1265
+ const existingMessageId = existingMessageIds[0];
1266
+ if (existingMessageId) {
1267
+ const updated = await sender.updateCard(existingMessageId, payload);
1268
+ if (updated) {
1269
+ nextMessageIds.push(existingMessageId);
1270
+ }
1271
+ else {
1272
+ const replacementMessageId = await sender.sendCard(conversationId, payload);
1273
+ if (replacementMessageId) {
1274
+ void sender.deleteMessage(existingMessageId).catch(() => undefined);
1275
+ nextMessageIds.push(replacementMessageId);
1276
+ }
1277
+ }
1278
+ }
1279
+ else {
1280
+ const newMessageId = await sender.sendCard(conversationId, payload);
1281
+ if (newMessageId) {
1282
+ nextMessageIds.push(newMessageId);
1283
+ }
1284
+ }
1285
+ for (let index = 1; index < existingMessageIds.length; index++) {
1286
+ const redundantMessageId = existingMessageIds[index];
1287
+ if (!redundantMessageId) {
1288
+ continue;
1289
+ }
1290
+ void sender.deleteMessage(redundantMessageId).catch(() => undefined);
1291
+ }
1292
+ if (nextMessageIds.length > 0) {
1293
+ outputBuffer.setMessageId(buffer.key, nextMessageIds[0]);
1294
+ streamCardMessageIdsMap.set(buffer.key, nextMessageIds);
1295
+ }
1296
+ else {
1297
+ streamCardMessageIdsMap.delete(buffer.key);
1298
+ }
1299
+ if (buffer.status !== 'running') {
1300
+ streamContentMap.delete(buffer.key);
1301
+ streamToolStateMap.delete(buffer.key);
1302
+ streamTimelineMap.delete(buffer.key);
1303
+ streamCardMessageIdsMap.delete(buffer.key);
1304
+ clearPartSnapshotsForSession(buffer.sessionId);
1305
+ outputBuffer.clear(buffer.key);
1306
+ }
1307
+ return;
1308
+ }
1309
+ const cards = buildStreamCards({
1310
+ ...cardData,
1311
+ messageId: existingMessageIds[0] || undefined,
1312
+ }, {
1313
+ componentBudget: STREAM_CARD_COMPONENT_BUDGET,
1314
+ });
1315
+ const nextMessageIds = [];
1316
+ const sender = feishuAdapter.getSender();
1317
+ for (let index = 0; index < cards.length; index++) {
1318
+ const card = cards[index];
1319
+ const existingMessageId = existingMessageIds[index];
1320
+ if (existingMessageId) {
1321
+ const updated = await sender.updateCard(existingMessageId, card);
1322
+ if (updated) {
1323
+ nextMessageIds.push(existingMessageId);
1324
+ continue;
1325
+ }
1326
+ const replacementMessageId = await sender.sendCard(conversationId, card);
1327
+ if (replacementMessageId) {
1328
+ void sender.deleteMessage(existingMessageId).catch(() => undefined);
1329
+ nextMessageIds.push(replacementMessageId);
1330
+ }
1331
+ else {
1332
+ nextMessageIds.push(existingMessageId);
1333
+ }
1334
+ continue;
1335
+ }
1336
+ const newMessageId = await sender.sendCard(conversationId, card);
1337
+ if (newMessageId) {
1338
+ nextMessageIds.push(newMessageId);
1339
+ }
1340
+ }
1341
+ for (let index = cards.length; index < existingMessageIds.length; index++) {
1342
+ const redundantMessageId = existingMessageIds[index];
1343
+ if (!redundantMessageId) {
1344
+ continue;
1345
+ }
1346
+ void sender.deleteMessage(redundantMessageId).catch(() => undefined);
1347
+ }
1348
+ if (nextMessageIds.length > 0) {
1349
+ outputBuffer.setMessageId(buffer.key, nextMessageIds[0]);
1350
+ streamCardMessageIdsMap.set(buffer.key, nextMessageIds);
1351
+ }
1352
+ else {
1353
+ streamCardMessageIdsMap.delete(buffer.key);
1354
+ }
1355
+ cardData.messageId = nextMessageIds[0] || undefined;
1356
+ cardData.thinkingMessageId = undefined;
1357
+ upsertLiveCardInteraction(conversationId, buffer.replyMessageId, cardData, nextMessageIds, null, buffer.openCodeMsgId);
1358
+ if (buffer.status !== 'running') {
1359
+ streamContentMap.delete(buffer.key);
1360
+ streamToolStateMap.delete(buffer.key);
1361
+ streamTimelineMap.delete(buffer.key);
1362
+ streamCardMessageIdsMap.delete(buffer.key);
1363
+ clearPartSnapshotsForSession(buffer.sessionId);
1364
+ outputBuffer.clear(buffer.key);
1365
+ }
1366
+ });
1367
+ // 3.5 初始化 Reliability 生命周期(heartbeat + scheduler + rescue orchestrator)
1368
+ const reliabilityLifecycle = bootstrapReliabilityLifecycle();
1369
+ // 4. 监听飞书消息(通过路由器分发)
1370
+ feishuClient.on('message', async (event) => {
1371
+ await reliabilityLifecycle.onInboundMessage();
1372
+ await rootRouter.onMessage(event);
1373
+ });
1374
+ feishuClient.on('chatUnavailable', (chatId) => {
1375
+ console.warn(`[Index] 检测到不可用群聊,移除会话绑定: ${chatId}`);
1376
+ chatSessionStore.removeSession(chatId);
1377
+ });
1378
+ // 5. 监听飞书卡片动作(通过路由器分发)
1379
+ feishuClient.setCardActionHandler(async (event) => {
1380
+ return await rootRouter.onAction(event);
1381
+ });
1382
+ discordAdapter.onMessage(async (event) => {
1383
+ await discordHandler.handleMessage(event);
1384
+ });
1385
+ discordAdapter.onInteraction(async (interaction) => {
1386
+ await discordHandler.handleInteraction(interaction);
1387
+ });
1388
+ // 6. OpenCode 事件监听已移至 openCodeEventHub(单一入口)
1389
+ // 6.5 注入事件处理上下文到 OpenCode Event Hub(必须在所有辅助函数声明之后)
1390
+ const applyFailureToSession = async (sessionID, errorText) => {
1391
+ const conversation = resolveSessionConversation(sessionID);
1392
+ if (!conversation)
1393
+ return;
1394
+ const platform = conversation.platform;
1395
+ const conversationId = conversation.conversationId;
1396
+ const dedupeKey = `${sessionID}:${errorText}`;
1397
+ if (errorNoticeMap.get(sessionID) === dedupeKey) {
1398
+ return;
1399
+ }
1400
+ errorNoticeMap.set(sessionID, dedupeKey);
1401
+ const bufferKey = buildBufferKeyBySession(sessionID, conversationId);
1402
+ const existingBuffer = outputBuffer.get(bufferKey) || outputBuffer.getOrCreate(bufferKey, conversationId, sessionID, null);
1403
+ upsertTimelineNote(bufferKey, `error:${sessionID}:${errorText}`, `❌ ${errorText}`, 'error');
1404
+ outputBuffer.append(bufferKey, `\n\n❌ ${errorText}`);
1405
+ outputBuffer.touch(bufferKey);
1406
+ outputBuffer.setStatus(bufferKey, 'failed');
1407
+ if (!existingBuffer.messageId) {
1408
+ const sender = platform === 'discord' ? discordAdapter.getSender() : feishuAdapter.getSender();
1409
+ await sender.sendText(conversationId, `❌ ${errorText}`);
1410
+ }
1411
+ };
1412
+ openCodeEventHub.setContext({
1413
+ CORRELATION_CACHE_TTL_MS,
1414
+ streamContentMap,
1415
+ reasoningSnapshotMap,
1416
+ textSnapshotMap,
1417
+ retryNoticeMap,
1418
+ errorNoticeMap,
1419
+ streamCardMessageIdsMap,
1420
+ toolCallChatMap,
1421
+ messageChatMap,
1422
+ streamToolStateMap,
1423
+ streamTimelineMap,
1424
+ toSessionId,
1425
+ toNonEmptyString,
1426
+ setCorrelationChatRef,
1427
+ getCorrelationChatRef,
1428
+ resolvePermissionChat,
1429
+ normalizeToolStatus,
1430
+ getToolStatusText,
1431
+ stringifyToolOutput,
1432
+ asRecord,
1433
+ pickFirstDefined,
1434
+ buildToolTraceOutput,
1435
+ clipToolTrace,
1436
+ mergeToolOutput,
1437
+ getOrCreateToolStateBucket,
1438
+ syncToolsToBuffer,
1439
+ upsertToolState,
1440
+ markActiveToolsCompleted,
1441
+ appendTextFromPart,
1442
+ appendReasoningFromPart,
1443
+ clearPartSnapshotsForSession,
1444
+ formatProviderError,
1445
+ upsertLiveCardInteraction,
1446
+ getTimelineSegments,
1447
+ getPendingPermissionForChat,
1448
+ getPendingQuestionForBuffer,
1449
+ applyFailureToSession,
1450
+ upsertTimelineNote,
1451
+ appendTimelineText,
1452
+ setTimelineText,
1453
+ upsertTimelineTool,
1454
+ });
1455
+ // 注册 OpenCode 事件监听器(单一入口)
1456
+ openCodeEventHub.register();
1457
+ // 7. 监听生命周期事件 (需要在启动后注册)
1458
+ feishuClient.onMemberLeft(async (chatId, memberId) => {
1459
+ await lifecycleHandler.handleMemberLeft(chatId, memberId);
1460
+ });
1461
+ feishuClient.onChatDisbanded(async (chatId) => {
1462
+ console.log(`[Index] 群 ${chatId} 已解散`);
1463
+ if (reliabilityConfig.cronOrphanAutoCleanup) {
1464
+ cleanupRuntimeCronJobsByConversation(getRuntimeCronManager(), 'feishu', chatId);
1465
+ }
1466
+ chatSessionStore.removeSession(chatId);
1467
+ });
1468
+ feishuClient.onMessageRecalled(async (event) => {
1469
+ // 处理撤回
1470
+ // event.message_id, event.chat_id
1471
+ // 如果撤回的消息是该会话最后一条 User Message,则触发 Undo
1472
+ const chatId = event.chat_id;
1473
+ const recalledMsgId = event.message_id;
1474
+ if (chatId && recalledMsgId) {
1475
+ const session = chatSessionStore.getSession(chatId);
1476
+ if (session && session.lastFeishuUserMsgId === recalledMsgId) {
1477
+ console.log(`[Index] 检测到用户撤回最后一条消息: ${recalledMsgId}`);
1478
+ await commandHandler.handleUndo(chatId);
1479
+ }
1480
+ }
1481
+ });
1482
+ // 7.5. 启动 Discord 适配器(如果启用)
1483
+ try {
1484
+ await discordAdapter.start();
1485
+ }
1486
+ catch (e) {
1487
+ console.error('[Discord] 启动失败:', e);
1488
+ // Discord 启动失败不影响 Feishu 流程
1489
+ }
1490
+ // 8. 启动飞书客户端
1491
+ await feishuClient.start();
1492
+ // 9. 启动清理检查
1493
+ await lifecycleHandler.cleanUpOnStart();
1494
+ console.log('✅ 服务已就绪');
1495
+ // 优雅退出处理
1496
+ let shuttingDown = false;
1497
+ const gracefulShutdown = async (signal) => {
1498
+ if (shuttingDown) {
1499
+ return;
1500
+ }
1501
+ shuttingDown = true;
1502
+ console.log(`\n[${signal}] 正在关闭服务...`);
1503
+ // 停止 reliability 调度和救援资源
1504
+ try {
1505
+ await reliabilityLifecycle.cleanup();
1506
+ }
1507
+ catch (e) {
1508
+ console.error('停止 reliability 资源失败:', e);
1509
+ }
1510
+ // 停止 Discord 适配器
1511
+ try {
1512
+ discordAdapter.stop();
1513
+ }
1514
+ catch (e) {
1515
+ console.error('停止 Discord 适配器失败:', e);
1516
+ }
1517
+ // 停止飞书连接
1518
+ try {
1519
+ feishuClient.stop();
1520
+ }
1521
+ catch (e) {
1522
+ console.error('停止飞书连接失败:', e);
1523
+ }
1524
+ // 断开 OpenCode 连接
1525
+ try {
1526
+ opencodeClient.disconnect();
1527
+ }
1528
+ catch (e) {
1529
+ console.error('断开 OpenCode 失败:', e);
1530
+ }
1531
+ // 清理所有缓冲区和定时器
1532
+ try {
1533
+ outputBuffer.clearAll();
1534
+ delayedResponseHandler.cleanupExpired(0);
1535
+ questionHandler.cleanupExpired(0);
1536
+ }
1537
+ catch (e) {
1538
+ console.error('清理资源失败:', e);
1539
+ }
1540
+ // 延迟退出以确保所有清理完成
1541
+ setTimeout(() => {
1542
+ console.log('✅ 服务已安全关闭');
1543
+ process.exit(0);
1544
+ }, 500);
1545
+ };
1546
+ process.on('SIGINT', () => {
1547
+ void gracefulShutdown('SIGINT');
1548
+ });
1549
+ process.on('SIGTERM', () => {
1550
+ void gracefulShutdown('SIGTERM');
1551
+ });
1552
+ process.on('SIGUSR2', () => {
1553
+ void gracefulShutdown('SIGUSR2');
1554
+ }); // nodemon 重启信号
1555
+ }
1556
+ if (process.env.VITEST !== 'true') {
1557
+ main().catch(error => {
1558
+ console.error('Fatal Error:', error);
1559
+ process.exit(1);
1560
+ });
1561
+ }
1562
+ //# sourceMappingURL=index.js.map