@wu529778790/open-im 1.11.8-beta.2 → 1.11.8-beta.21

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 (60) hide show
  1. package/dist/adapters/codebuddy-adapter.js +3 -17
  2. package/dist/adapters/codex-adapter.js +3 -16
  3. package/dist/adapters/opencode-adapter.d.ts +0 -5
  4. package/dist/adapters/opencode-adapter.js +10 -25
  5. package/dist/adapters/registry.js +10 -1
  6. package/dist/clawbot/client.js +72 -78
  7. package/dist/clawbot/qr-login.d.ts +1 -0
  8. package/dist/clawbot/qr-login.js +7 -0
  9. package/dist/codebuddy/cli-runner.d.ts +2 -22
  10. package/dist/codebuddy/cli-runner.js +9 -27
  11. package/dist/codex/cli-runner.d.ts +2 -22
  12. package/dist/codex/cli-runner.js +24 -75
  13. package/dist/commands/handler.d.ts +1 -0
  14. package/dist/commands/handler.js +34 -0
  15. package/dist/config/types.d.ts +23 -0
  16. package/dist/config-web-auth.d.ts +13 -0
  17. package/dist/config-web-auth.js +114 -0
  18. package/dist/config-web-browser.d.ts +4 -0
  19. package/dist/config-web-browser.js +48 -0
  20. package/dist/config-web-cors.d.ts +5 -0
  21. package/dist/config-web-cors.js +48 -0
  22. package/dist/config-web-health.d.ts +7 -0
  23. package/dist/config-web-health.js +65 -0
  24. package/dist/config-web-http.d.ts +3 -0
  25. package/dist/config-web-http.js +31 -0
  26. package/dist/config-web-page-i18n.d.ts +36 -2
  27. package/dist/config-web-page-i18n.js +36 -2
  28. package/dist/config-web-payload.d.ts +84 -0
  29. package/dist/config-web-payload.js +260 -0
  30. package/dist/config-web-probes.d.ts +2 -0
  31. package/dist/config-web-probes.js +271 -0
  32. package/dist/config-web.d.ts +3 -11
  33. package/dist/config-web.js +45 -815
  34. package/dist/config.js +22 -1
  35. package/dist/constants.d.ts +10 -0
  36. package/dist/constants.js +11 -0
  37. package/dist/opencode/cli-runner.d.ts +2 -22
  38. package/dist/opencode/cli-runner.js +47 -68
  39. package/dist/opencode/sdk-manager.d.ts +11 -0
  40. package/dist/opencode/sdk-manager.js +46 -0
  41. package/dist/opencode/sdk-runner.d.ts +7 -0
  42. package/dist/opencode/sdk-runner.js +225 -0
  43. package/dist/platform/handle-ai-request.js +18 -1
  44. package/dist/qq/client.js +45 -53
  45. package/dist/shared/ai-task.d.ts +15 -0
  46. package/dist/shared/ai-task.js +174 -2
  47. package/dist/shared/cli-runner-base.d.ts +39 -0
  48. package/dist/shared/cli-runner-base.js +108 -0
  49. package/dist/shared/connection-manager.d.ts +111 -0
  50. package/dist/shared/connection-manager.js +157 -0
  51. package/dist/shared/session-invalid-detector.d.ts +5 -0
  52. package/dist/shared/session-invalid-detector.js +20 -0
  53. package/dist/wework/client.js +79 -127
  54. package/dist/workbuddy/client.js +40 -74
  55. package/package.json +4 -1
  56. package/web/dist/assets/index-6e51Wtaa.css +1 -0
  57. package/web/dist/assets/index-Da9qKWA2.js +57 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-XKVTb-0p.css +0 -1
  60. package/web/dist/assets/index-yDVGC_84.js +0 -57
package/dist/qq/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { createLogger } from "../logger.js";
3
- import { jitteredDelay, SLOW_PROBE_MS } from "../shared/reconnect.js";
3
+ import { ReconnectManager, HeartbeatMonitor } from "../shared/connection-manager.js";
4
4
  const log = createLogger("QQ");
5
5
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
6
6
  const API_BASE = "https://api.sgroup.qq.com";
@@ -12,25 +12,23 @@ const INTENTS = {
12
12
  };
13
13
  let client = null;
14
14
  let ws = null;
15
- let heartbeatTimer = null;
16
- let reconnectTimer = null;
17
15
  let stopped = false;
18
16
  let seq = null;
19
17
  let sessionId = null;
20
- let reconnectAttempt = 0;
21
18
  let connecting = false; // 防止并发 connectWebSocket
22
19
  let currentConfig = null;
23
20
  let currentHandler = null;
24
21
  let tokenState = null;
25
- let lastServerResponseTime = 0;
26
- function clearTimers() {
27
- if (heartbeatTimer) {
28
- clearInterval(heartbeatTimer);
29
- heartbeatTimer = null;
30
- }
31
- if (reconnectTimer) {
32
- clearTimeout(reconnectTimer);
33
- reconnectTimer = null;
22
+ // Connection infrastructure (shared managers replace raw timers/counters)
23
+ const heartbeatMonitor = new HeartbeatMonitor();
24
+ const reconnectManager = new ReconnectManager({
25
+ name: 'QQ',
26
+ backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
27
+ onReconnect: () => reconnectFn(),
28
+ });
29
+ async function reconnectFn() {
30
+ if (currentConfig && currentHandler) {
31
+ await connectWebSocket(currentConfig, currentHandler);
34
32
  }
35
33
  }
36
34
  function buildAuthHeaders(token) {
@@ -143,28 +141,28 @@ function normalizeInboundEvent(payload) {
143
141
  }
144
142
  return null;
145
143
  }
146
- function startHeartbeat(intervalMs) {
147
- lastServerResponseTime = Date.now();
148
- if (heartbeatTimer)
149
- clearInterval(heartbeatTimer);
150
- heartbeatTimer = setInterval(() => {
151
- if (!ws || ws.readyState !== WebSocket.OPEN)
152
- return;
153
- const elapsed = Date.now() - lastServerResponseTime;
154
- if (lastServerResponseTime > 0 && elapsed > intervalMs * 3) {
155
- log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
156
- clearTimers();
157
- ws?.terminate();
158
- connectWebSocket(currentConfig, currentHandler);
159
- return;
160
- }
161
- try {
162
- ws.send(JSON.stringify({ op: 1, d: seq }));
163
- }
164
- catch (err) {
165
- log.warn('QQ heartbeat send failed:', err);
166
- }
167
- }, intervalMs);
144
+ /**
145
+ * Heartbeat tick: send ping and detect stale connections.
146
+ * The heartbeat interval is dynamic (from QQ gateway HELLO message).
147
+ */
148
+ let heartbeatIntervalMs = 30000;
149
+ function heartbeatTick() {
150
+ if (!ws || ws.readyState !== WebSocket.OPEN)
151
+ return;
152
+ if (heartbeatMonitor.isStale(heartbeatIntervalMs * 3)) {
153
+ const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
154
+ log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
155
+ heartbeatMonitor.stop();
156
+ ws?.terminate();
157
+ reconnectFn().catch((err) => log.error('QQ reconnect from heartbeat failed:', err));
158
+ return;
159
+ }
160
+ try {
161
+ ws.send(JSON.stringify({ op: 1, d: seq }));
162
+ }
163
+ catch (err) {
164
+ log.warn('QQ heartbeat send failed:', err);
165
+ }
168
166
  }
169
167
  async function connectWebSocket(config, handler) {
170
168
  // 防止并发连接
@@ -196,17 +194,17 @@ async function connectWebSocket(config, handler) {
196
194
  };
197
195
  socket.on("open", () => {
198
196
  log.info("QQ gateway connected");
199
- reconnectAttempt = 0;
197
+ reconnectManager.reset();
200
198
  });
201
199
  socket.on("message", async (raw) => {
202
- lastServerResponseTime = Date.now();
200
+ heartbeatMonitor.recordResponse();
203
201
  try {
204
202
  const payload = JSON.parse(raw.toString());
205
203
  if (typeof payload.s === "number")
206
204
  seq = payload.s;
207
205
  if (payload.op === 10) {
208
- const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
209
- startHeartbeat(heartbeatInterval);
206
+ heartbeatIntervalMs = Number(payload.d?.heartbeat_interval ?? 30000);
207
+ heartbeatMonitor.start(heartbeatIntervalMs, heartbeatTick);
210
208
  socket.send(JSON.stringify({
211
209
  op: sessionId ? 6 : 2,
212
210
  d: sessionId
@@ -253,7 +251,7 @@ async function connectWebSocket(config, handler) {
253
251
  });
254
252
  socket.on("close", (code, reason) => {
255
253
  settle(() => { }); // 清理 ready timeout
256
- clearTimers();
254
+ heartbeatMonitor.stop();
257
255
  ws = null;
258
256
  const reasonStr = reason.toString();
259
257
  if (code === 4009) {
@@ -273,19 +271,11 @@ async function connectWebSocket(config, handler) {
273
271
  seq = null;
274
272
  }
275
273
  const isFatalClose = code === 4004 || code === 4006 || code === 4007;
276
- const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
277
- const delay = isFatalClose ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
278
274
  if (isFatalClose) {
279
- log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(SLOW_PROBE_MS / 1000)}s 一次)`);
275
+ reconnectManager.setFatal(true);
276
+ log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
280
277
  }
281
- reconnectAttempt += 1;
282
- reconnectTimer = setTimeout(() => {
283
- if (currentConfig && currentHandler) {
284
- connectWebSocket(currentConfig, currentHandler).catch((err) => {
285
- log.error("QQ reconnect failed:", err);
286
- });
287
- }
288
- }, delay);
278
+ reconnectManager.schedule();
289
279
  });
290
280
  });
291
281
  }
@@ -306,6 +296,8 @@ export async function initQQ(config, eventHandler) {
306
296
  stopped = false;
307
297
  currentConfig = config;
308
298
  currentHandler = eventHandler;
299
+ reconnectManager.reset();
300
+ reconnectManager.resume();
309
301
  client = {
310
302
  sendPrivateMessage: async (openid, content, replyToMessageId) => {
311
303
  const res = await apiRequest(config, "POST", `/v2/users/${openid}/messages`, buildMessageBody(content, replyToMessageId));
@@ -328,7 +320,8 @@ export async function initQQ(config, eventHandler) {
328
320
  }
329
321
  export async function stopQQ() {
330
322
  stopped = true;
331
- clearTimers();
323
+ heartbeatMonitor.stop();
324
+ reconnectManager.stop();
332
325
  if (ws) {
333
326
  ws.close(1000);
334
327
  ws = null;
@@ -339,5 +332,4 @@ export async function stopQQ() {
339
332
  tokenState = null;
340
333
  sessionId = null;
341
334
  seq = null;
342
- lastServerResponseTime = 0;
343
335
  }
@@ -4,9 +4,24 @@
4
4
  import type { Config } from '../config.js';
5
5
  import type { SessionManager } from '../session/session-manager.js';
6
6
  import type { ToolAdapter } from '../adapters/tool-adapter.interface.js';
7
+ /** 查询指定用户的 autopilot 等待状态(供 /autopilot 命令使用)。 */
8
+ export declare function getAutopilotPendingStatus(userId: string): {
9
+ retryAt: Date;
10
+ type: string;
11
+ retryCount: number;
12
+ } | undefined;
13
+ /** 清除指定用户的 autopilot 状态(测试用)。 */
14
+ export declare function clearAutopilotState(userId: string): void;
7
15
  export interface TaskDeps {
8
16
  config: Config;
9
17
  sessionManager: SessionManager;
18
+ /**
19
+ * Autopilot 回调:限流恢复后调用,将 autoResumePrompt 作为新消息
20
+ * 通过平台的请求队列重新入队。如果不提供,autopilot 不执行恢复动作。
21
+ */
22
+ autopilot?: {
23
+ onAutoPilotContinue: (prompt: string) => void;
24
+ };
10
25
  }
11
26
  export interface TaskContext {
12
27
  userId: string;
@@ -6,8 +6,99 @@ import { captureError } from './sentry.js';
6
6
  import { formatToolStats, formatToolCallNotification, getContextWarning, getAIToolDisplayName, toReplyPlainText, } from './utils.js';
7
7
  import { createLogger } from '../logger.js';
8
8
  const log = createLogger('AITask');
9
+ /** 每用户连续 autopilot 重试计数。成功完成或遇到非限流错误时清零。 */
10
+ const autopilotRetryCount = new Map();
11
+ /** 当前等待中的 autopilot 定时器(用于 /autopilot 状态查询)。 */
12
+ const pendingAutopilotTimers = new Map();
13
+ /** 查询指定用户的 autopilot 等待状态(供 /autopilot 命令使用)。 */
14
+ export function getAutopilotPendingStatus(userId) {
15
+ return pendingAutopilotTimers.get(userId);
16
+ }
17
+ /** 清除指定用户的 autopilot 状态(测试用)。 */
18
+ export function clearAutopilotState(userId) {
19
+ autopilotRetryCount.delete(userId);
20
+ pendingAutopilotTimers.delete(userId);
21
+ }
22
+ /**
23
+ * 判断错误是否为限流类错误(用于决定是否保留 session)。
24
+ * 覆盖所有已知的限流模式:429/529/session limit/overloaded/temporary throttle。
25
+ */
9
26
  function isUsageLimitError(error) {
10
- return /usage limit/i.test(error) || /try again at\s+\d{1,2}:\d{2}\s*(AM|PM)/i.test(error);
27
+ return /usage\s*limit/i.test(error)
28
+ || /try\s+again\s+at\s+\d{1,2}:\d{2}/i.test(error)
29
+ || /rate\s*limit/i.test(error)
30
+ || /\b429\b/.test(error)
31
+ || /\b529\b/.test(error)
32
+ || /overloaded/i.test(error)
33
+ || /temporarily\s+limiting/i.test(error)
34
+ || /session\s+limit/i.test(error)
35
+ || /you['\u2019]ve\s+hit\s+your/i.test(error);
36
+ }
37
+ /**
38
+ * 从错误消息中提取限流详情。仅在 isUsageLimitError() 返回 true 后调用。
39
+ */
40
+ function classifyRateLimit(error, config) {
41
+ const now = Date.now();
42
+ const shortMs = config.autopilot.shortRetrySeconds * 1000;
43
+ const defaultMs = config.autopilot.defaultIntervalHours * 3600 * 1000;
44
+ // Session quota / usage limit(最常见,等待时间最长)
45
+ if (/session\s*limit|usage\s*limit|opus\s*limit|you['\u2019]ve\s+hit\s+your/i.test(error)) {
46
+ const timeMatch = error.match(/try\s+again\s+at\s+(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
47
+ if (timeMatch) {
48
+ let hours = parseInt(timeMatch[1], 10);
49
+ const minutes = parseInt(timeMatch[2], 10);
50
+ const ampm = timeMatch[3]?.toUpperCase();
51
+ if (ampm === 'PM' && hours < 12)
52
+ hours += 12;
53
+ if (ampm === 'AM' && hours === 12)
54
+ hours = 0;
55
+ const retryAt = new Date();
56
+ retryAt.setHours(hours, minutes, 0, 0);
57
+ if (retryAt.getTime() <= now)
58
+ retryAt.setDate(retryAt.getDate() + 1);
59
+ return { detected: true, type: 'session_limit', retryAt, isLongWait: true };
60
+ }
61
+ return { detected: true, type: 'session_limit', retryAt: new Date(now + defaultMs), isLongWait: true };
62
+ }
63
+ // 529 overloaded / temporarily limiting(短延迟)
64
+ if (/\b529\b|overloaded|temporarily\s+limiting/i.test(error)) {
65
+ return {
66
+ detected: true,
67
+ type: /overloaded/i.test(error) ? 'overloaded' : 'temporary',
68
+ retryAt: new Date(now + shortMs),
69
+ isLongWait: false,
70
+ };
71
+ }
72
+ // 429 rate limit(使用默认周期)
73
+ if (/\b429\b|rate\s*limit/i.test(error)) {
74
+ return { detected: true, type: 'api_rate_limit', retryAt: new Date(now + defaultMs), isLongWait: true };
75
+ }
76
+ return { detected: false, type: null, retryAt: null, isLongWait: false };
77
+ }
78
+ function rateLimitTypeLabel(type) {
79
+ switch (type) {
80
+ case 'session_limit': return '会话额度限制';
81
+ case 'api_rate_limit': return 'API 速率限制';
82
+ case 'overloaded': return '服务器过载';
83
+ case 'temporary': return '临时限流';
84
+ default: return '限流';
85
+ }
86
+ }
87
+ function formatTimeHHMM(date) {
88
+ return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
89
+ }
90
+ function formatDuration(ms) {
91
+ if (ms <= 0)
92
+ return '即将';
93
+ const totalSec = Math.ceil(ms / 1000);
94
+ const hours = Math.floor(totalSec / 3600);
95
+ const minutes = Math.floor((totalSec % 3600) / 60);
96
+ const seconds = totalSec % 60;
97
+ if (hours > 0)
98
+ return `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}`;
99
+ if (minutes > 0)
100
+ return `${minutes}分钟${seconds > 0 ? `${seconds}秒` : ''}`;
101
+ return `${seconds}秒`;
11
102
  }
12
103
  /**
13
104
  * 将 AI/CLI 错误文本归类为一个稳定的 errorType 字符串,用于遥测聚合。
@@ -211,6 +302,8 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
211
302
  if (settled)
212
303
  return;
213
304
  settled = true;
305
+ // 成功完成 → 清除 autopilot 重试计数
306
+ autopilotRetryCount.delete(ctx.userId);
214
307
  if (pendingUpdate) {
215
308
  clearTimeout(pendingUpdate);
216
309
  pendingUpdate = null;
@@ -263,6 +356,83 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
263
356
  }
264
357
  settled = true;
265
358
  log.error(`Task error for user ${ctx.userId}: ${error}`);
359
+ // ── Autopilot 拦截 ──
360
+ const apConfig = config.autopilot;
361
+ const currentRetries = autopilotRetryCount.get(ctx.userId) ?? 0;
362
+ if (apConfig?.enabled &&
363
+ deps.autopilot &&
364
+ isUsageLimitError(error) &&
365
+ currentRetries < apConfig.maxRetries) {
366
+ const rateInfo = classifyRateLimit(error, config);
367
+ if (rateInfo.detected && rateInfo.retryAt) {
368
+ const retryCount = currentRetries + 1;
369
+ autopilotRetryCount.set(ctx.userId, retryCount);
370
+ // 记录等待状态
371
+ pendingAutopilotTimers.set(ctx.userId, {
372
+ retryAt: rateInfo.retryAt,
373
+ type: rateInfo.type,
374
+ retryCount,
375
+ });
376
+ // 通知用户
377
+ const typeLabel = rateLimitTypeLabel(rateInfo.type);
378
+ const timeStr = formatTimeHHMM(rateInfo.retryAt);
379
+ const remaining = formatDuration(rateInfo.retryAt.getTime() - Date.now());
380
+ const statusMsg = `⏳ 检测到${typeLabel},将在 ${timeStr}(${remaining}后)自动恢复 (${retryCount}/${apConfig.maxRetries})`;
381
+ log.info(`[Autopilot] ${statusMsg} for user ${ctx.userId}`);
382
+ try {
383
+ await platformAdapter.sendError(hadSessionInvalid
384
+ ? '当前 Claude 会话已失效,已自动执行 /new 重置会话,请重新发送刚才的问题。'
385
+ : `${error}\n\n${statusMsg}`);
386
+ }
387
+ catch (err) {
388
+ log.error('Failed to send autopilot status:', err);
389
+ }
390
+ cleanup();
391
+ resolve();
392
+ // 异步等待 → 恢复(不阻塞 Promise 解决)
393
+ const retryAt = rateInfo.retryAt;
394
+ const delayMs = Math.max(retryAt.getTime() - Date.now(), 0);
395
+ log.info(`[Autopilot] Scheduling retry in ${delayMs}ms for user ${ctx.userId}`);
396
+ // 分段定时器(setTimeout 最大 ~24.8 天)
397
+ const MAX_SEGMENT = 2_147_483_647; // 2^31 - 1
398
+ const scheduleRetry = (remaining) => {
399
+ if (remaining <= 0) {
400
+ // 定时器到期 → 清除状态 → 发送恢复消息
401
+ pendingAutopilotTimers.delete(ctx.userId);
402
+ log.info(`[Autopilot] Timer fired for user ${ctx.userId}, sending "${apConfig.autoResumePrompt}"`);
403
+ try {
404
+ platformAdapter.streamUpdate('', `🔄 限额已恢复,正在自动重试... (${retryCount})`);
405
+ }
406
+ catch {
407
+ /* ignore */
408
+ }
409
+ deps.autopilot.onAutoPilotContinue(apConfig.autoResumePrompt);
410
+ return;
411
+ }
412
+ const segment = Math.min(remaining, MAX_SEGMENT);
413
+ const timer = setTimeout(() => scheduleRetry(remaining - segment), segment);
414
+ if (typeof timer === 'object' && 'unref' in timer)
415
+ timer.unref();
416
+ // 如果任务被 abort(/new、cancelUser),取消待执行的定时器
417
+ if (ctx.signal) {
418
+ if (ctx.signal.aborted) {
419
+ clearTimeout(timer);
420
+ pendingAutopilotTimers.delete(ctx.userId);
421
+ return;
422
+ }
423
+ ctx.signal.addEventListener('abort', () => {
424
+ clearTimeout(timer);
425
+ pendingAutopilotTimers.delete(ctx.userId);
426
+ log.info(`[Autopilot] Timer cancelled by abort for user ${ctx.userId}`);
427
+ }, { once: true });
428
+ }
429
+ };
430
+ scheduleRetry(delayMs);
431
+ return;
432
+ }
433
+ }
434
+ // ── 非限流错误或 autopilot 未启用/耗尽重试 ──
435
+ autopilotRetryCount.delete(ctx.userId);
266
436
  if (isUsageLimitError(error)) {
267
437
  // Usage limit errors: keep session for all tools (user can retry later)
268
438
  log.warn(`Keeping ${aiCommand} session for user ${ctx.userId} after usage limit error`);
@@ -290,7 +460,9 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
290
460
  }, {
291
461
  model: aiCommand === 'claude'
292
462
  ? (sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel)
293
- : undefined,
463
+ : aiCommand === 'opencode'
464
+ ? config.opencodeModel
465
+ : undefined,
294
466
  chatId: ctx.chatId,
295
467
  // 默认跳过权限确认,保持全自动执行(可通过 config 或环境变量关闭)
296
468
  skipPermissions: config.skipPermissions ?? true,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared CLI runner utilities — stderr buffering, finalize gating,
3
+ * wall-clock timeout, and abort handle.
4
+ *
5
+ * Used by codex, codebuddy, and opencode CLI runners to eliminate
6
+ * duplicated process lifecycle management code.
7
+ */
8
+ import { type ChildProcess, type SpawnOptions } from 'node:child_process';
9
+ import type { Interface as ReadlineInterface } from 'node:readline';
10
+ import type { RunHandle } from '../adapters/tool-adapter.interface.js';
11
+ export declare const MAX_STDERR_HEAD: number;
12
+ export declare const MAX_STDERR_TAIL: number;
13
+ export interface StderrBuffer {
14
+ head: string;
15
+ tail: string;
16
+ total: number;
17
+ headFull: boolean;
18
+ }
19
+ export declare function createStderrBuffer(): StderrBuffer;
20
+ export declare function appendStderr(buf: StderrBuffer, text: string): void;
21
+ export declare function reconstructStderr(buf: StderrBuffer): string;
22
+ export interface FinalizeGate {
23
+ rlClosed: boolean;
24
+ childClosed: boolean;
25
+ }
26
+ export declare function createFinalizeGate(): FinalizeGate;
27
+ export declare function isFinalizeReady(gate: FinalizeGate): boolean;
28
+ export declare function startWallClockTimeout(child: ChildProcess, timeoutMs: number, toolName: string, log: {
29
+ warn: (...args: unknown[]) => void;
30
+ }, onTimeout: () => void, extraCleanup?: () => void): ReturnType<typeof setTimeout>;
31
+ export declare function clearWallClockTimeout(handle: ReturnType<typeof setTimeout> | null): void;
32
+ export declare function createAbortHandle(completed: {
33
+ value: boolean;
34
+ }, child: ChildProcess, timeoutHandle: {
35
+ value: ReturnType<typeof setTimeout> | null;
36
+ }, rl?: ReadlineInterface): RunHandle;
37
+ export declare function buildBaseEnv(extra?: Record<string, string | undefined>): NodeJS.ProcessEnv;
38
+ export declare function buildBaseSpawnOptions(workDir: string, stdio?: ('pipe' | 'ignore' | 'inherit')[], extraEnv?: Record<string, string | undefined>): SpawnOptions;
39
+ export declare function spawnCliProcess(command: string, args: string[], workDir: string, stdio?: ('pipe' | 'ignore' | 'inherit')[], extraEnv?: Record<string, string | undefined>): ChildProcess;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared CLI runner utilities — stderr buffering, finalize gating,
3
+ * wall-clock timeout, and abort handle.
4
+ *
5
+ * Used by codex, codebuddy, and opencode CLI runners to eliminate
6
+ * duplicated process lifecycle management code.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
10
+ import { killProcessTree, trackChild } from './process-kill.js';
11
+ // ── stderr head/tail buffer ──────────────────────────────────────
12
+ export const MAX_STDERR_HEAD = 4 * 1024;
13
+ export const MAX_STDERR_TAIL = 6 * 1024;
14
+ export function createStderrBuffer() {
15
+ return { head: '', tail: '', total: 0, headFull: false };
16
+ }
17
+ export function appendStderr(buf, text) {
18
+ buf.total += text.length;
19
+ if (!buf.headFull) {
20
+ const room = MAX_STDERR_HEAD - buf.head.length;
21
+ if (room > 0) {
22
+ buf.head += text.slice(0, room);
23
+ if (buf.head.length >= MAX_STDERR_HEAD)
24
+ buf.headFull = true;
25
+ }
26
+ }
27
+ buf.tail += text;
28
+ if (buf.tail.length > MAX_STDERR_TAIL) {
29
+ buf.tail = buf.tail.slice(-MAX_STDERR_TAIL);
30
+ }
31
+ }
32
+ export function reconstructStderr(buf) {
33
+ if (buf.total === 0)
34
+ return '';
35
+ if (!buf.headFull)
36
+ return buf.head;
37
+ if (buf.total <= MAX_STDERR_HEAD + MAX_STDERR_TAIL) {
38
+ return buf.head + buf.tail.slice(buf.tail.length - (buf.total - MAX_STDERR_HEAD));
39
+ }
40
+ return (buf.head +
41
+ `\n\n... (omitted ${buf.total - MAX_STDERR_HEAD - MAX_STDERR_TAIL} bytes) ...\n\n` +
42
+ buf.tail);
43
+ }
44
+ export function createFinalizeGate() {
45
+ return { rlClosed: false, childClosed: false };
46
+ }
47
+ export function isFinalizeReady(gate) {
48
+ return gate.rlClosed && gate.childClosed;
49
+ }
50
+ // ── wall-clock timeout ───────────────────────────────────────────
51
+ export function startWallClockTimeout(child, timeoutMs, toolName, log, onTimeout, extraCleanup) {
52
+ const handle = setTimeout(() => {
53
+ log.warn(`${toolName} CLI 超过 ${timeoutMs}ms,强制终止 (pid=${child.pid})`);
54
+ extraCleanup?.();
55
+ onTimeout();
56
+ killProcessTree(child);
57
+ }, timeoutMs);
58
+ handle.unref();
59
+ return handle;
60
+ }
61
+ export function clearWallClockTimeout(handle) {
62
+ if (handle)
63
+ clearTimeout(handle);
64
+ }
65
+ // ── abort handle ─────────────────────────────────────────────────
66
+ export function createAbortHandle(completed, child, timeoutHandle, rl) {
67
+ return {
68
+ abort: () => {
69
+ if (completed.value)
70
+ return;
71
+ completed.value = true;
72
+ clearWallClockTimeout(timeoutHandle.value);
73
+ timeoutHandle.value = null;
74
+ rl?.close();
75
+ killProcessTree(child);
76
+ },
77
+ };
78
+ }
79
+ // ── environment and spawn configuration ─────────────────────────
80
+ export function buildBaseEnv(extra) {
81
+ const env = processEnvForNonClaudeCliChild();
82
+ if (process.platform === 'win32') {
83
+ env.LANG = env.LANG || 'C.UTF-8';
84
+ env.LC_ALL = env.LC_ALL || 'C.UTF-8';
85
+ }
86
+ if (extra) {
87
+ for (const [key, value] of Object.entries(extra)) {
88
+ if (value !== undefined)
89
+ env[key] = value;
90
+ }
91
+ }
92
+ return env;
93
+ }
94
+ export function buildBaseSpawnOptions(workDir, stdio = ['pipe', 'pipe', 'pipe'], extraEnv) {
95
+ const opts = {
96
+ cwd: workDir,
97
+ detached: process.platform !== 'win32',
98
+ stdio,
99
+ env: buildBaseEnv(extraEnv),
100
+ windowsHide: process.platform === 'win32',
101
+ };
102
+ return opts;
103
+ }
104
+ export function spawnCliProcess(command, args, workDir, stdio = ['pipe', 'pipe', 'pipe'], extraEnv) {
105
+ const child = spawn(command, args, buildBaseSpawnOptions(workDir, stdio, extraEnv));
106
+ trackChild(child);
107
+ return child;
108
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared connection management infrastructure for self-managing platform clients.
3
+ *
4
+ * Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
5
+ * qq, and workbuddy clients:
6
+ *
7
+ * - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
8
+ * - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
9
+ * - **StateManager**: state variable with change-notification callback
10
+ */
11
+ export interface ReconnectManagerConfig {
12
+ /** Platform name used in log messages. */
13
+ name: string;
14
+ /**
15
+ * Delay strategy:
16
+ * - `{ mode: 'exponential', baseMs, maxMs, cap }` — exponential backoff
17
+ * `min(baseMs * 1.5^floor(attempt / cap), maxMs)`.
18
+ * - `{ mode: 'stepped', delays }` — stepped array; last entry repeats.
19
+ */
20
+ backoff: {
21
+ mode: 'exponential';
22
+ baseMs: number;
23
+ maxMs: number;
24
+ cap?: number;
25
+ } | {
26
+ mode: 'stepped';
27
+ delays: number[];
28
+ };
29
+ /**
30
+ * After this many attempts the counter resets and retries continue
31
+ * (avoids permanent disconnection). 0 = unlimited (no reset).
32
+ */
33
+ maxAttempts?: number;
34
+ /** Callback invoked after the computed delay. */
35
+ onReconnect: () => void | Promise<void>;
36
+ }
37
+ /**
38
+ * Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
39
+ *
40
+ * Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
41
+ * timer cleanup, attempt counting, backoff computation, jitter, logging.
42
+ */
43
+ export declare class ReconnectManager {
44
+ private timer;
45
+ private attempt;
46
+ private _fatal;
47
+ private _stopped;
48
+ private readonly log;
49
+ private readonly cfg;
50
+ constructor(cfg: ReconnectManagerConfig);
51
+ get attemptCount(): number;
52
+ get fatal(): boolean;
53
+ get stopped(): boolean;
54
+ /** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
55
+ setFatal(value: boolean): void;
56
+ /** Reset attempt counter and fatal flag after a successful connection. */
57
+ reset(): void;
58
+ /**
59
+ * Schedule a reconnect attempt. Clears any pending timer, computes the
60
+ * backoff delay (with jitter), and invokes `onReconnect` after the delay.
61
+ */
62
+ schedule(): void;
63
+ /** Cancel any pending reconnect timer. */
64
+ stop(): void;
65
+ /** Allow scheduling again (undo `stop`). */
66
+ resume(): void;
67
+ private clearTimer;
68
+ private computeBaseDelay;
69
+ }
70
+ /**
71
+ * Manages a setInterval-based heartbeat or watchdog timer.
72
+ *
73
+ * Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
74
+ * timer cleanup on re-start, clearInterval on stop.
75
+ */
76
+ export declare class HeartbeatMonitor {
77
+ private timer;
78
+ private _lastResponseTime;
79
+ get lastResponseTime(): number;
80
+ /** Record that a response was received (for watchdog stale detection). */
81
+ recordResponse(): void;
82
+ /**
83
+ * Start a periodic callback. Clears any existing timer first.
84
+ * @param intervalMs Interval in milliseconds.
85
+ * @param onTick Callback invoked on each tick.
86
+ */
87
+ start(intervalMs: number, onTick: () => void | Promise<void>): void;
88
+ /** Clear the heartbeat timer. */
89
+ stop(): void;
90
+ /**
91
+ * Watchdog convenience: returns true if more than `staleMs` have elapsed
92
+ * since the last `recordResponse()` call.
93
+ */
94
+ isStale(staleMs: number): boolean;
95
+ }
96
+ /**
97
+ * Manages a connection state variable with change-notification callback.
98
+ *
99
+ * Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
100
+ * patterns across wework, clawbot, and workbuddy clients.
101
+ */
102
+ export declare class StateManager<T extends string> {
103
+ private _state;
104
+ private onChange?;
105
+ constructor(initialState: T);
106
+ get current(): T;
107
+ /** Register a callback invoked whenever the state changes. */
108
+ setOnChange(handler: ((state: T) => void) | undefined): void;
109
+ /** Transition to a new state and notify the callback if it changed. */
110
+ set(newState: T): void;
111
+ }