@raolin2025/claude-code-node 2.7.6 → 2.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.7.6",
3
+ "version": "2.7.8",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
5
5
  "type": "module",
6
6
  "main": "src/core/index.js",
@@ -0,0 +1,92 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { TelegramListener } from '../channel/tg-listener.js'
4
+
5
+ const tick = (ms = 10) => new Promise((r) => setTimeout(r, ms))
6
+
7
+ /**
8
+ * 构造一个用 mock _fetch 的 TelegramListener,用于验证 flushOffset 的排他锁逻辑。
9
+ * 为避免 _poll 无限循环导致测试进程无法退出,测试通过直接设置挂起状态来驱动。
10
+ */
11
+ function makeListener() {
12
+ const listener = new TelegramListener({ channels: { telegram: { token: 'test-token' } } })
13
+ listener._loadOffset = () => 0
14
+ listener._saveOffset = () => {}
15
+
16
+ const calls = []
17
+ // 模拟一个可被 abort 的挂起请求(等价于 _poll 正在长轮询挂起)
18
+ listener._fetch = async (url, options = {}) => {
19
+ const body = JSON.parse(options.body || '{}')
20
+ calls.push({ tgTimeout: body.timeout, httpTimeout: options.timeout })
21
+
22
+ // Telegram 长轮询 = body.timeout > 0(如 _poll 传 30);flushOffset 传 0 立即返回。
23
+ if ((body.timeout || 0) > 0) {
24
+ return new Promise((resolve, reject) => {
25
+ if (options.signal) {
26
+ options.signal.addEventListener('abort', () => {
27
+ const e = new Error('aborted')
28
+ e.name = 'AbortError'
29
+ reject(e)
30
+ }, { once: true })
31
+ }
32
+ })
33
+ }
34
+ return { ok: true, json: async () => ({ ok: true, result: [] }) }
35
+ }
36
+
37
+ return { listener, calls }
38
+ }
39
+
40
+ test('flushOffset 会取消挂起的 _poll 长轮询并独占连接(消除 409 竞态)', async () => {
41
+ const { listener, calls } = makeListener()
42
+
43
+ // 模拟 _poll 正处于长轮询挂起:设置 _polling 与一个可 abort 的 AbortController
44
+ const pollAbort = new AbortController()
45
+ listener._polling = true
46
+ listener._pollAbort = pollAbort
47
+ // 让 mock 的挂起请求真正绑定到该 signal(模拟 _poll 正在 await 它)
48
+ const pendingReq = listener._fetch(`${listener.bot.apiBase}/getUpdates`, {
49
+ method: 'POST', headers: {}, timeout: 60000, signal: pollAbort.signal,
50
+ body: JSON.stringify({ offset: 1, timeout: 30 }),
51
+ })
52
+ // 预先消费可能的拒绝(flush 会 abort 它),避免 unhandled rejection 被记为测试失败
53
+ const pendingReqSettled = pendingReq.catch(() => {})
54
+
55
+ // 调用 flushOffset(模拟 /quit 前的确认消费)
56
+ const flushPromise = listener.flushOffset()
57
+ await tick(20)
58
+
59
+ // flush 应立即 abort 掉挂起的 poll 请求
60
+ assert.strictEqual(pollAbort.signal.aborted, true, 'flushOffset 应 abort 挂起的 _poll 长轮询')
61
+ // 等待挂起请求因 abort 结束
62
+ await pendingReqSettled
63
+ // flushOffset 自身不应直接修改 _polling(由 _poll 的 finally 清理)
64
+ assert.strictEqual(listener._polling, true, 'flush 不应直接改 _polling(由 _poll 的 finally 清理)')
65
+ // 模拟真实 _poll 的 finally:请求结束后立即清理 _polling(真实 _poll 会这样做),
66
+ // 使 flushOffset 的等待循环立即退出,贴近真实时序并避免 2s deadline。
67
+ listener._polling = false
68
+ await flushPromise
69
+
70
+ assert.strictEqual(listener._flushLock, false, 'flush 应释放排他锁')
71
+
72
+ // flush 应发起 body.timeout:0 的 getUpdates(确认消费),且不与他人并发
73
+ const flushCall = calls.find((c) => c.tgTimeout === 0)
74
+ assert.strictEqual(flushCall !== undefined, true, 'flushOffset 应发起 timeout:0 的 getUpdates')
75
+ // 恰好 2 次请求:1 次挂起的长轮询 + 1 次 flush(无并发重复请求)
76
+ assert.strictEqual(calls.length, 2, '不应有多余的并发 getUpdates')
77
+
78
+ listener.conversations.destroy() // 清理 setInterval 定时器,避免测试进程残留
79
+ })
80
+
81
+ test('flushOffset 未挂起长轮询时也能正常 flush', async () => {
82
+ const { listener, calls } = makeListener()
83
+
84
+ await listener.flushOffset()
85
+
86
+ const flushCall = calls.find((c) => c.tgTimeout === 0)
87
+ assert.strictEqual(flushCall !== undefined, true, 'flushOffset 应发起确认消费请求')
88
+ assert.strictEqual(calls.length, 1, '仅 1 次请求(flush)')
89
+ assert.strictEqual(listener._flushLock, false, 'flush 应释放排他锁')
90
+
91
+ listener.conversations.destroy() // 清理 setInterval 定时器,避免测试进程残留
92
+ })
@@ -284,6 +284,11 @@ export class TelegramListener {
284
284
  this._lastPollError = null // 最近一次轮询错误消息(用于去抖刷屏)
285
285
  this._lastPollErrorAt = 0 // 最近一次轮询错误时间戳
286
286
  this._msgQueue = Promise.resolve() // 消息串行处理队列(避免阻塞轮询循环)
287
+ // 排他锁 — 解决 flushOffset 与 _poll 长轮询并发触发 getUpdates 导致 409 Conflict。
288
+ // Telegram 同一 bot token 同一时刻只允许一个 getUpdates 长轮询。
289
+ this._pollAbort = null // 当前 _poll 挂起请求的 AbortController(供 flush 取消)
290
+ this._flushLock = false // true = flushOffset 正在独占 bot 连接
291
+ this._polling = false // 是否正处于 _poll 请求挂起中(await 未返回)
287
292
  this.conversations = new ConversationState()
288
293
  this._onMessage = null
289
294
  this._handlers = {}
@@ -329,11 +334,23 @@ export class TelegramListener {
329
334
  /** 内部轮询 */
330
335
  async _poll() {
331
336
  while (this.running) {
337
+ // flushOffset 持有排他锁时,不发起新的 getUpdates,避免与 flush 并发导致 409。
338
+ // flush 会 abort 当前挂起请求并设置锁,这里等待锁释放后再继续。
339
+ if (this._flushLock) {
340
+ await this._sleep(50)
341
+ continue
342
+ }
343
+
344
+ // 为本次请求创建 AbortController,flushOffset 可通过它取消挂起的长轮询,
345
+ // 从而独占 bot 连接、消除 409 竞态。
346
+ this._pollAbort = new AbortController()
347
+ this._polling = true
332
348
  try {
333
349
  const url = `${this.bot.apiBase}/getUpdates`
334
350
  const res = await this._fetch(url, {
335
351
  method: 'POST',
336
352
  headers: { 'Content-Type': 'application/json' },
353
+ signal: this._pollAbort.signal,
337
354
  // 长轮询本身最多等 30s(Telegram API 参数),HTTP 层超时必须更长,
338
355
  // 否则代理路径(fetchViaSocks5)30s 硬超时会误杀长轮询 → HTTP request timeout 刷屏。
339
356
  timeout: 60000,
@@ -379,6 +396,11 @@ export class TelegramListener {
379
396
  this._retryDelay = 1000
380
397
 
381
398
  } catch (e) {
399
+ // flushOffset 通过 abort 取消挂起请求以独占连接 — 属于预期中断,静默处理不告警。
400
+ // 回到循环顶部,等待 flushOffset 释放 _flushLock 后继续轮询。
401
+ if (this._flushLock && e.name === 'AbortError') {
402
+ continue
403
+ }
382
404
  // 错误去抖:连续相同的错误(如长轮询超时)只在首次/状态变化时打印,
383
405
  // 避免 AI 处理长任务时 getUpdates 空转超时不断刷屏。
384
406
  const msg = e.message || 'unknown'
@@ -392,6 +414,10 @@ export class TelegramListener {
392
414
  }
393
415
  await this._sleep(this._retryDelay)
394
416
  this._retryDelay = Math.min(this._retryDelay * 2, this.maxRetryDelay)
417
+ } finally {
418
+ // 请求结束(成功/失败/abort)— 清理挂起状态,供 flushOffset 判断 poll 已让出
419
+ this._polling = false
420
+ this._pollAbort = null
395
421
  }
396
422
  }
397
423
  }
@@ -694,10 +720,28 @@ export class TelegramListener {
694
720
  * 同步强杀进程,没有机会再 poll 一次 → 未确认的 update 残留在服务器。
695
721
  * 调用 getUpdates(offset=lastUpdateId+1) 可让 Telegram 立即确认消费。
696
722
  * 返回 Promise,调用方应 await 后再退出。
723
+ *
724
+ * 加固:Telegram 同一 bot token 同时只允许一个 getUpdates 长轮询,
725
+ * 若 flush 与 _poll 正在挂起的长轮询并发,会触发 409 Conflict。
726
+ * 因此 flush 先通过 AbortController 取消当前 _poll 请求、独占 bot 连接,
727
+ * 再发起自己的 getUpdates,彻底消除 409 竞态。
697
728
  */
698
729
  async flushOffset() {
699
730
  if (!this.bot) return
731
+ // 已持有排他锁(理论上不会重入,防御性直接返回)
732
+ if (this._flushLock) return
733
+
734
+ // 1. 取得排他锁:先取消正在挂起的 _poll 长轮询,再等它完全让出连接。
735
+ this._flushLock = true
700
736
  try {
737
+ if (this._pollAbort) this._pollAbort.abort()
738
+ // 等待 _poll 从 await 返回并清理 _polling(最长 2s,防止极端情况下卡死退出)
739
+ const deadline = Date.now() + 2000
740
+ while (this._polling && Date.now() < deadline) {
741
+ await this._sleep(20)
742
+ }
743
+
744
+ // 2. 独占发起 getUpdates 确认消费(timeout:0 立即返回,不挂长轮询)
701
745
  const url = `${this.bot.apiBase}/getUpdates`
702
746
  const res = await this._fetch(url, {
703
747
  method: 'POST',
@@ -716,7 +760,12 @@ export class TelegramListener {
716
760
  return
717
761
  }
718
762
  }
719
- } catch {}
763
+ } catch {
764
+ // flush 失败不影响退出,offset 已持久化到磁盘,双保险兜底
765
+ } finally {
766
+ // 3. 释放排他锁,_poll 循环顶部检测到后继续轮询(进程随即退出,正常路径用不到)
767
+ this._flushLock = false
768
+ }
720
769
  }
721
770
  }
722
771
 
@@ -279,12 +279,28 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
279
279
  reject(new Error('HTTP request timeout'))
280
280
  }, timeoutMs)
281
281
 
282
+ // 支持 AbortController 取消(如 flushOffset 想中断挂起的长轮询以独占 bot 连接)。
283
+ // 触发 abort 时销毁 socket,使挂起的请求立即以 AbortError 结束,而不是等 HTTP 超时。
284
+ const signal = options.signal
285
+ const onAbort = () => {
286
+ const err = new Error('The operation was aborted')
287
+ err.name = 'AbortError'
288
+ clearTimeout(timeout)
289
+ socket.destroy()
290
+ reject(err)
291
+ }
292
+ if (signal) {
293
+ if (signal.aborted) return onAbort()
294
+ signal.addEventListener('abort', onAbort, { once: true })
295
+ }
296
+
282
297
  socket.write(reqBuf)
283
298
  socket.on('data', (chunk) => {
284
299
  responseData += chunk.toString()
285
300
  })
286
301
  socket.on('end', () => {
287
302
  clearTimeout(timeout)
303
+ if (signal) signal.removeEventListener('abort', onAbort)
288
304
  // 解析 HTTP 响应
289
305
  const headerEnd = responseData.indexOf('\r\n\r\n')
290
306
  if (headerEnd < 0) {
@@ -306,6 +322,7 @@ export async function fetchViaSocks5(url, options = {}, proxyAddr) {
306
322
  })
307
323
  socket.on('error', (err) => {
308
324
  clearTimeout(timeout)
325
+ if (signal) signal.removeEventListener('abort', onAbort)
309
326
  reject(err)
310
327
  })
311
328
  })
package/src/core/cli.js CHANGED
@@ -588,10 +588,6 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
588
588
  let currentSource = 'cli'
589
589
  // 等待中的 Telegram 权限确认(resolve 回调)— 远程用户回复 y/n/a 时响应
590
590
  let pendingConfirm = null
591
- // 等待中的 Telegram AskUserQuestion 回答(resolve 回调)— 远程用户回复任意文本时响应
592
- let pendingAskUser = null
593
- // 取消当前等待中的 AskUserQuestion(用户 /stop 时调用,resolve 为取消标记,避免死锁)
594
- let cancelAskUser = null
595
591
  // 最近一次 /models 拉取的模型列表(供 /model <编号> 选择)
596
592
  let modelList = []
597
593
  // /models 从 Telegram 发出后,等待用户回复编号/名字来选择模型
@@ -611,14 +607,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
611
607
  // 关键:来自 Telegram 的消息,如果当前正在等待远程权限确认(pendingConfirm),
612
608
  // 则把它当作确认回复(y/n/a)处理,而不是当作新的 REPL 输入。
613
609
  // 注意:/ 开头的命令优先走命令分支,不被权限确认拦截(否则 /quit、/stop 会被吞)。
614
- // 优先处理 pendingAskUser(AskUserQuestion 需要任意文本答案,且通常是用户正在等待的问题)。
615
- if (source === 'telegram' && pendingAskUser && !trimmed.startsWith('/')) {
616
- const ask = pendingAskUser
617
- pendingAskUser = null
618
- cancelAskUser = null
619
- ask.fn(trimmed)
620
- return
621
- }
610
+ // (AskUserQuestion 已改为异步发问,不再依赖 pendingAskUser 挂起结构——用户回复
611
+ // 作为正常消息进入引擎处理,无锁死/发呆问题。)
622
612
  if (source === 'telegram' && pendingConfirm && !trimmed.startsWith('/')) {
623
613
  const confirm = pendingConfirm
624
614
  pendingConfirm = null
@@ -812,13 +802,7 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
812
802
  break
813
803
  case 'stop':
814
804
  // 停止当前正在运行的 AI 工作(如卡死 / 长时间无响应时)。
815
- // 若正在等待 AskUserQuestion 回答,先取消提问(resolve 取消标记),避免引擎挂起。
816
- if (pendingAskUser) {
817
- const cancel = cancelAskUser
818
- pendingAskUser = null
819
- cancelAskUser = null
820
- if (cancel) cancel()
821
- }
805
+ // AskUserQuestion 已改为异步发问(不挂起),无需在此取消 pending 等待。
822
806
  if (engine.state.isRunning) {
823
807
  engine.abort()
824
808
  console.log('⏹️ 已发送停止信号,正在中止当前工作...')
@@ -1157,25 +1141,19 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
1157
1141
  }
1158
1142
  engine.config.readline = rl
1159
1143
 
1160
- // AskUserQuestion 工具回调 — 按当前来源分流:
1161
- // - Telegram 远程模式:推送问题到 Telegram 并等待远程回复(带超时兜底,避免死锁)
1162
- // - CLI 本地模式:走本地终端交互(inputCtrl.ask)
1144
+ // AskUserQuestion 工具回调 — 按通道分流:
1145
+ // - Telegram 可用时:异步发问——发问题到 Telegram 后立即返回(不挂起 Promise、不锁死引擎),
1146
+ // 用户后续回复作为正常消息进入,由下一轮引擎处理时把回复当作回答。
1147
+ // 避免旧"挂起等待"结构导致会话卡死/发呆。
1148
+ // - CLI 本地模式:同步等待本地终端输入。
1163
1149
  engine.config.onAskUser = (question) => {
1164
- if (currentSource === 'telegram' && tgListener?.bot) {
1165
- const promptText = `❓ ${question}\n\n请直接回复你的回答。\n(发 /stop 可取消本次提问)`
1150
+ if (tgListener?.bot && tgChatId) {
1151
+ const promptText = `❓ ${question}\n\n请直接回复你的回答。`
1166
1152
  sendTelegram(promptText, null).catch(() => {})
1167
- return new Promise((resolve) => {
1168
- // 一直等待用户回答——不设超时自行结束(超时会吞掉迟到回答,违背"等待回答"语义)。
1169
- // 用户回复任意文本即作为回答;用户 /stop 则通过 cancelAskUser 取消(resolve 取消标记,避免死锁)。
1170
- pendingAskUser = { fn: (val) => resolve(val) }
1171
- cancelAskUser = () => {
1172
- if (pendingAskUser) pendingAskUser = null
1173
- cancelAskUser = null
1174
- resolve('(提问已取消)')
1175
- }
1176
- })
1153
+ // 立即返回,不挂起;用户回复会在下一轮以正常消息进入
1154
+ return `(已向用户提问,等待回复): ${question}`
1177
1155
  }
1178
- // CLI 本地模式
1156
+ // CLI 本地模式:同步等待终端输入
1179
1157
  return askQuestion(`❓ ${question}\n> `)
1180
1158
  }
1181
1159