agentquad 0.4.4 → 0.4.7

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.
@@ -7,7 +7,7 @@ import { homedir } from 'node:os'
7
7
  import pidusage from 'pidusage'
8
8
  import { loadConfig, resolveToolsConfig, SUPPORTED_TOOLS, DEFAULT_ROOT_DIR } from '../config.js'
9
9
  import { writeRuntimeMcpConfig } from '../agent-installer-shared.js'
10
- import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt } from '../permission-prompt.js'
10
+ import { CLAUDE_DEFAULT_PERMISSION_OPTIONS, extractPermissionPrompt, formatToolUseAsPrompt, parsePermissionOptions } from '../permission-prompt.js'
11
11
  import { findLatestPendingToolUse } from '../claude-transcript.js'
12
12
 
13
13
  const MAX_OUTPUT_BUFFER = 5 * 1024 * 1024
@@ -189,16 +189,31 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
189
189
  // 也翻 pending_confirm,会让 session 在 AI 完成回话之后无故卡在"待确认",前端没有任何
190
190
  // 入口能把它清掉(focus 只清 unread;markSessionRunningAfterInput 需要真实输入)。
191
191
  // 所以 idle / 其它非 running 状态下,直接拒绝翻转。
192
+ //
193
+ // 例外 allowIdleFlip=true:来自 Claude PTY-detector 兜底路径。它要求 anchor + ≥2 个数字
194
+ // 选项才 emit,假阳性概率极低;而 Claude 在 `permissions.defaultMode='auto'` 或用户在
195
+ // 终端里 Shift+Tab 切到 plan/acceptEdits 时,权限框可能在 Stop hook 之后(status=idle)
196
+ // 才浮出来——这种 case 真权限信号下,必须允许从 idle 翻 pending_confirm。
197
+ //
192
198
  // promptText 可由 caller 显式传入(Codex 的 prompt-detector 已经握有一段干净文本);
193
199
  // 不传则从 session.recentOutput 提取尾部(Claude Notification 路径走这条)。
194
200
  // 状态已经是 pending_confirm 时也允许更新 pendingPrompt —— 同一轮等待中 PTY
195
201
  // 可能继续追加输出(如选项变化、高亮位移),让前端拿到最新文案。
196
- function markPendingConfirm(sessionId, { source = null, promptText = null } = {}) {
202
+ function markPendingConfirm(sessionId, { source = null, promptText = null, allowIdleFlip = false } = {}) {
197
203
  const session = sessions.get(sessionId)
198
204
  if (!session) return false
199
205
  if (!LIVE_AI_STATUSES.has(session.status)) return false
206
+ // Cursor 的 notification 语义跟 Claude 完全不一样:cursor-hooks 把 cursor 的
207
+ // `beforeSubmitPrompt`("我要把 prompt 发给 AI 了")映射成 notification 事件,
208
+ // 它**不是**权限请求。如果照旧翻 pending_confirm,cursor session 在 acceptEdits
209
+ // (--force) 模式下每次用户发消息都被卡进"待确认"状态出不来。
210
+ // 现阶段 cursor 没有"真权限请求"的可靠信号(cursor-prompt-detector 不存在,
211
+ // cursor TUI 也没像 Claude 那样的 Esc to cancel 页脚),所以直接拒所有 cursor
212
+ // 的 pending_confirm 翻转。如果将来加 cursor 专用 detector,再放开。
213
+ if (session.tool === 'cursor') return false
200
214
  const wasPending = session.status === 'pending_confirm'
201
- if (!wasPending && session.status !== 'running') return false
215
+ const wasIdle = session.status === 'idle'
216
+ if (!wasPending && session.status !== 'running' && !(wasIdle && allowIdleFlip)) return false
202
217
 
203
218
  let text = ''
204
219
  let options = []
@@ -219,18 +234,24 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
219
234
  }
220
235
 
221
236
  // 兜底:从 PTY 提取(Codex 主路径 / Claude jsonl 拿不到时的 backup)。
222
- // recentOutput 是 4KB 滑窗,TUI redraw 抖动会冲掉真实 prompt 文本;
223
- // 再兜底用 outputHistory(最大 5MB)的尾部 ~64KB,让 extractor 能找到锚点。
224
237
  if (!text) {
225
- const extractSource = promptText || session.recentOutput || ''
226
- let historicalRaw = null
227
- if (!promptText && Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
228
- const joined = session.outputHistory.join('')
229
- historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
238
+ if (promptText) {
239
+ // caller 显式给了 promptText(codex-prompt-detector / claude-prompt-detector
240
+ // 已经在自己那侧做过严格匹配 + 清洗,这里完全相信,只跑一遍 options 解析)
241
+ text = String(promptText).slice(-1200)
242
+ options = parsePermissionOptions(text)
243
+ } else {
244
+ // 真正只从 PTY 缓冲爬:recentOutput 4KB 滑窗 + outputHistory 5MB 尾部
245
+ // extractPermissionPrompt 内部走严格 anchor + 数字选项 + 末尾 footer 的窗口规则。
246
+ let historicalRaw = null
247
+ if (Array.isArray(session.outputHistory) && session.outputHistory.length > 0) {
248
+ const joined = session.outputHistory.join('')
249
+ historicalRaw = joined.length > 65536 ? joined.slice(-65536) : joined
250
+ }
251
+ const r = extractPermissionPrompt(session.recentOutput || '', { historicalRaw })
252
+ text = r.text
253
+ options = r.options
230
254
  }
231
- const r = extractPermissionPrompt(extractSource, { historicalRaw })
232
- text = r.text
233
- options = r.options
234
255
  }
235
256
  const hasContent = !!(text || options.length)
236
257
  const prevPrompt = session.permissionPrompt || null
@@ -509,7 +530,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
509
530
  })
510
531
 
511
532
  // ─── 程序化 session 启动入口(供 orchestrator 等模块直接调用,跳过 HTTP) ───
512
- function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null }) {
533
+ function spawnSession({ todoId, prompt, tool, cwd, resumeNativeId, permissionMode, label, extraEnv, sessionId: externalSessionId, skipTelegram = false, ignoreExistingNativeSessionId = false, parentTodoId = null, suppressStaleTurnDetect = false }) {
513
534
  if (!todoId || typeof prompt !== 'string' || !tool) {
514
535
  const err = new Error('missing todoId, prompt, or tool'); err.code = 'bad_request'
515
536
  throw err
@@ -636,6 +657,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
636
657
  extraEnv: { ...(extraEnv || {}), ...autoEnv },
637
658
  mcpConfigPath: runtimeMcpPath,
638
659
  codexMcpUrl,
660
+ suppressStaleTurnDetect,
639
661
  })
640
662
  // 2. 读出 preset nativeId(claude 新会话 = randomUUID, resume = resumeNativeId, codex 新 = null)。
641
663
  // 这是让"首屏即正确"成立的核心:先于 db.updateTodo 拿到值。
@@ -661,18 +683,20 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
661
683
  ...(label ? { label } : {}),
662
684
  }),
663
685
  })
664
- // 4. 5s 兜底:前端如果一直没发合法 init(极少见 — /exec 返回后 WS 还没连上),
686
+ // 4. 30s 兜底:前端如果一直没发合法 init(极少见 — 旧版本前端 / 网络真的挂了),
665
687
  // 用老的 80×24 兜底 spawn,避免 session 永远卡在 create 状态。
688
+ // 30s(不是 5s)的理由:新前端在隐藏挂载时会延迟到 IO 可见才发 init,主人停留在
689
+ // conversation tab 默认体验里也不应该撞兜底;30s 既给足切换窗口、又保留挂掉时的退路。
666
690
  session.spawnFallbackTimer = setTimeout(() => {
667
691
  session.spawnFallbackTimer = null
668
692
  if (session.spawned) return
669
- console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 5s)`)
693
+ console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 30s)`)
670
694
  session.spawned = true
671
695
  pty.startWithSize(sessionId, 80, 24).catch((e) => {
672
696
  console.warn(`[ai-terminal] spawn fallback failed: ${e.message}`)
673
697
  session.spawned = false
674
698
  })
675
- }, 5000)
699
+ }, 30000)
676
700
  session.spawnFallbackTimer.unref?.()
677
701
  } catch (error) {
678
702
  sessions.delete(sessionId)
@@ -762,6 +786,9 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
762
786
  outputBytesTotal: s.outputBytesTotal || 0,
763
787
  awaitingReply: !!s.awaitingReply,
764
788
  permissionPrompt: s.permissionPrompt || null,
789
+ // s 是 route 自己的 sessions Map 里的对象;usage 是 PtyManager 的 watcher
790
+ // 写在它自己 sessions Map 的对象上,必须显式跨读,不能直接 s.usage。
791
+ usage: pty.getUsage(sessionId),
765
792
  })
766
793
  }
767
794
  res.json({ ok: true, sessions: out })
@@ -1058,20 +1085,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1058
1085
  session.autoMode = nextAutoMode
1059
1086
  broadcastToSession(session, { type: 'auto_mode', autoMode: session.autoMode || null })
1060
1087
 
1061
- if (nextAutoMode !== 'bypass' || session.tool !== 'claude') return
1088
+ // 对称重启:claude/codex/cursor 三家 CLI permission mode 都是启动参数注入,
1089
+ // 运行时切换必须重启 PTY 才能让新 mode 真正生效。只要前后 effective mode 不同就重启;
1090
+ // 相同 / 工具不支持 / 没有 nativeSessionId 时退路(broadcast / 软通知)。
1091
+ const prevEffective = session.permissionMode || 'default'
1092
+ const nextEffective = nextAutoMode || 'default'
1093
+ if (prevEffective === nextEffective) return
1094
+ const RESTARTABLE_TOOLS = new Set(['claude', 'codex', 'cursor'])
1095
+ if (!RESTARTABLE_TOOLS.has(session.tool)) return
1062
1096
 
1063
1097
  if (!session.nativeSessionId) {
1064
1098
  sendToBrowser(ws, {
1065
1099
  type: 'auto_mode_notice',
1066
- autoMode: 'bypass',
1100
+ autoMode: nextAutoMode,
1067
1101
  immediate: false,
1068
1102
  reason: 'native_session_missing',
1069
- message: '当前 Claude 会话尚未拿到原生 session id,全托管将仅对后续启动/恢复的会话生效。',
1103
+ message: '当前会话尚未拿到原生 session id,模式切换将仅对后续启动/恢复的会话生效。',
1070
1104
  })
1071
1105
  return
1072
1106
  }
1073
1107
 
1074
- broadcastToSession(session, { type: 'auto_mode_switching', target: 'bypass' })
1108
+ broadcastToSession(session, { type: 'auto_mode_switching', target: nextEffective })
1075
1109
  const todoSnapshot = db.getTodo(session.todoId)
1076
1110
  session.replacedBySessionId = '__pending__'
1077
1111
  let restarted
@@ -1082,29 +1116,44 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1082
1116
  tool: session.tool,
1083
1117
  cwd: session.cwd || undefined,
1084
1118
  resumeNativeId: session.nativeSessionId,
1085
- permissionMode: 'bypass',
1086
- label: 'runtime:bypass',
1119
+ permissionMode: nextEffective,
1120
+ label: `runtime:${nextEffective}`,
1087
1121
  skipTelegram: true,
1088
1122
  ignoreExistingNativeSessionId: true,
1123
+ // 老 PTY 即将被 kill,新的 claude --resume 只是接管 jsonl —— 没有真跑新一轮。
1124
+ // 让 watcher 吃掉第一帧 stale 状态,避免它把刚翻成 idle 的状态再翻回 running。
1125
+ suppressStaleTurnDetect: true,
1089
1126
  })
1090
1127
  } catch (e) {
1091
1128
  delete session.replacedBySessionId
1092
1129
  restoreSessionAsCurrent(session, todoSnapshot)
1093
1130
  sendToBrowser(ws, {
1094
1131
  type: 'auto_mode_notice',
1095
- autoMode: 'bypass',
1132
+ autoMode: nextAutoMode,
1096
1133
  immediate: false,
1097
1134
  reason: 'restart_failed',
1098
- message: `切换全托管失败:${e.message}`,
1135
+ message: `切换托管模式失败:${e.message}`,
1099
1136
  })
1100
1137
  return
1101
1138
  }
1102
1139
 
1140
+ // 把新 session 翻到 idle:托管模式切换的语义就是"打断当前轮、换一套权限再起来",
1141
+ // 老 PTY 已经被 kill 在 mid-turn,新的 claude --resume 不会自动续上那一轮 ——
1142
+ // 它只是接管 jsonl 等用户输入。spawnSession 默认 status='running' 适用于"首次启动 +
1143
+ // CLI 直接吃 prompt"的场景;resume-after-mode-switch 都应该是 idle。
1144
+ // RESTARTABLE_TOOLS (claude/codex/cursor) 一视同仁——三家都是"resume 不续轮"的语义。
1145
+ const newSession = sessions.get(restarted.sessionId)
1146
+ if (newSession && LIVE_AI_STATUSES.has(newSession.status)) {
1147
+ newSession.status = 'idle'
1148
+ newSession.awaitingReply = true
1149
+ newSession.lastTurnDoneAt = Date.now()
1150
+ persistLiveSessionState(newSession, 'idle', 'ai_running', { lastTurnDoneAt: newSession.lastTurnDoneAt })
1151
+ }
1103
1152
  broadcastToSession(session, {
1104
1153
  type: 'session_restarted',
1105
1154
  oldSessionId: sessionId,
1106
1155
  newSessionId: restarted.sessionId,
1107
- autoMode: 'bypass',
1156
+ autoMode: nextEffective,
1108
1157
  })
1109
1158
  if (restarted.sessionId !== sessionId) {
1110
1159
  session.replacedBySessionId = restarted.sessionId
@@ -1231,6 +1280,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1231
1280
  nativeSessionMap.clear()
1232
1281
  }
1233
1282
 
1283
+ // recoverPendingTodosOnStartup 的 spawn 失败 catch 路径用:把 mergeTodoAiSessions
1284
+ // 之前刚写进 DB 的 status='running' 那条改回 'failed',其它 aiSessions 原样保留。
1285
+ function markRecoveryFailed(todoId, sessionId) {
1286
+ try {
1287
+ const todoNow = db.getTodo(todoId)
1288
+ if (!todoNow) return
1289
+ const aiSessions = Array.isArray(todoNow.aiSessions) ? todoNow.aiSessions : []
1290
+ let mutated = false
1291
+ const next = aiSessions.map((s) => {
1292
+ if (s?.sessionId === sessionId) {
1293
+ mutated = true
1294
+ return { ...s, status: 'failed', completedAt: Date.now() }
1295
+ }
1296
+ return s
1297
+ })
1298
+ if (mutated) db.updateTodo(todoId, { aiSessions: next })
1299
+ } catch (e) {
1300
+ console.warn('[ai-terminal] markRecoveryFailed failed:', e.message)
1301
+ }
1302
+ }
1303
+
1234
1304
  function recoverPendingTodosOnStartup() {
1235
1305
  // 启动期一次性读 config:恢复一条没记 permissionMode 的老 session 时回退到全局默认。
1236
1306
  // 用户在设置里选了"完全托管"但 DB 里没存 → 这里把意图重新接上,否则 claude --resume
@@ -1329,6 +1399,10 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1329
1399
  todoSessionMap.delete(todo.id)
1330
1400
  const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
1331
1401
  if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
1402
+ // recoverPendingTodosOnStartup 此前已 mergeTodoAiSessions 把该 session 写成
1403
+ // status='running';spawn 失败必须把那条改回 'failed',否则前端读到 running
1404
+ // 会渲染"运行中"且没有对应 PTY。与 markOrphanedSessionsAsFailed 互为冗余。
1405
+ markRecoveryFailed(todo.id, sessionId)
1332
1406
  db.updateTodo(todo.id, { status: 'todo' })
1333
1407
  })
1334
1408
  } catch (e) {
@@ -1337,6 +1411,7 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1337
1411
  todoSessionMap.delete(todo.id)
1338
1412
  const nativeKey = `${recoverable.tool}:${recoverable.nativeSessionId}`
1339
1413
  if (nativeSessionMap.get(nativeKey) === sessionId) nativeSessionMap.delete(nativeKey)
1414
+ markRecoveryFailed(todo.id, sessionId)
1340
1415
  db.updateTodo(todo.id, { status: 'todo' })
1341
1416
  }
1342
1417
  }
@@ -1372,8 +1447,40 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1372
1447
  }
1373
1448
  }
1374
1449
 
1450
+ // 服务硬重启 / crash 时 PTY 进程没机会触发 onExit,DB 里 aiSession.status='running'
1451
+ // (或 idle / pending_confirm) 会留作"僵尸",前端读到后渲染成「运行中」却没有对应 PTY。
1452
+ // 启动期一次性把所有"看起来还活着但无对应 live PTY"的 aiSession 改成 'failed'。
1453
+ // 必须在 recoverPendingTodosOnStartup 之后调用:成功 recover 的 session 此时已在
1454
+ // nativeSessionMap 里,扫描会跳过它们;只有真正的孤儿会被改写。
1455
+ function markOrphanedSessionsAsFailed() {
1456
+ const ALIVE_LOOKING = new Set(['running', 'idle', 'pending_confirm'])
1457
+ let swept = 0
1458
+ try {
1459
+ for (const todo of db.listTodos()) {
1460
+ const aiSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
1461
+ let changed = false
1462
+ const nextSessions = aiSessions.map((s) => {
1463
+ if (!s || !ALIVE_LOOKING.has(s.status)) return s
1464
+ const key = s.tool && s.nativeSessionId ? `${s.tool}:${s.nativeSessionId}` : null
1465
+ if (key && nativeSessionMap.has(key)) return s
1466
+ changed = true
1467
+ return { ...s, status: 'failed', completedAt: Date.now() }
1468
+ })
1469
+ if (!changed) continue
1470
+ db.updateTodo(todo.id, { aiSessions: nextSessions })
1471
+ swept += 1
1472
+ }
1473
+ if (swept > 0) {
1474
+ console.log(`[ai-terminal] orphan sweep: marked ${swept} sessions as failed`)
1475
+ }
1476
+ } catch (e) {
1477
+ console.warn('[ai-terminal] markOrphanedSessionsAsFailed failed:', e.message)
1478
+ }
1479
+ }
1480
+
1375
1481
  sweepStuckPendingConfirm()
1376
1482
  recoverPendingTodosOnStartup()
1483
+ markOrphanedSessionsAsFailed()
1377
1484
 
1378
1485
  return {
1379
1486
  router,
@@ -56,12 +56,14 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
56
56
  const sid = aiSess.sessionId
57
57
  if (!sid) continue
58
58
  const liveSess = aiTerminal.sessions.get(sid)
59
- const bridgeRoute = openclaw.resolveRoute?.(sid) || null
59
+ // per-channel route lookup(dual-bound session 时避免用错渠道的路由)
60
+ const bridgeTgRoute = openclaw.resolveRoute?.(sid, 'telegram') || null
61
+ const bridgeLarkRoute = openclaw.resolveRoute?.(sid, 'lark') || null
60
62
 
61
63
  // ── Telegram 分支 ──
62
64
  if (telegramEnabled) {
63
65
  const dbHasTgRoute = !!aiSess.telegramRoute?.threadId
64
- const bridgeHasTgRoute = bridgeIsTelegram(bridgeRoute)
66
+ const bridgeHasTgRoute = bridgeIsTelegram(bridgeTgRoute)
65
67
  if (isAlive(liveSess)) {
66
68
  if (!dbHasTgRoute && !bridgeHasTgRoute) {
67
69
  actions.push({
@@ -74,7 +76,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
74
76
  })
75
77
  }
76
78
  } else if ((dbHasTgRoute || bridgeHasTgRoute) && t.status !== 'done') {
77
- const tgRoute = aiSess.telegramRoute || (bridgeHasTgRoute ? bridgeRoute : null)
79
+ const tgRoute = aiSess.telegramRoute || (bridgeHasTgRoute ? bridgeTgRoute : null)
78
80
  if (tgRoute) {
79
81
  actions.push({
80
82
  type: 'close_topic',
@@ -93,7 +95,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
93
95
  // ── Lark 分支 ──
94
96
  if (larkEnabled) {
95
97
  const dbHasLarkRoute = !!aiSess.larkRoute?.rootMessageId
96
- const bridgeHasLarkRoute = bridgeIsLark(bridgeRoute)
98
+ const bridgeHasLarkRoute = bridgeIsLark(bridgeLarkRoute)
97
99
  if (isAlive(liveSess)) {
98
100
  if (!dbHasLarkRoute && !bridgeHasLarkRoute) {
99
101
  actions.push({
@@ -106,7 +108,7 @@ export function createTelegramSyncRouter({ db, aiTerminal, openclaw, wizard, get
106
108
  })
107
109
  }
108
110
  } else if ((dbHasLarkRoute || bridgeHasLarkRoute) && t.status !== 'done') {
109
- const larkRoute = aiSess.larkRoute || (bridgeHasLarkRoute ? bridgeRoute : null)
111
+ const larkRoute = aiSess.larkRoute || (bridgeHasLarkRoute ? bridgeLarkRoute : null)
110
112
  if (larkRoute) {
111
113
  actions.push({
112
114
  type: 'close_thread',
package/src/server.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  loadConfig,
14
14
  resolveToolsConfig,
15
15
  saveConfig,
16
+ withConfigLock,
16
17
  } from "./config.js";
17
18
  import { openDb } from "./db.js";
18
19
  import { PtyManager } from "./pty.js";
@@ -369,21 +370,21 @@ function buildNativeResumeCommand(tool, nativeSessionId, tools = {}) {
369
370
  }
370
371
 
371
372
  function mergeToolConfig(currentTool = {}, nextTool = {}) {
372
- const merged = {
373
+ // 用户字段即真理:直接合并,不再因为 command 变了就悄悄清空 bin。
374
+ // PTY 启动时 bin 为空会 fallback 到 command 名走 PATH 解析。
375
+ return {
373
376
  ...currentTool,
374
377
  ...nextTool,
375
378
  };
376
- const commandChanged =
377
- nextTool.command !== undefined &&
378
- nextTool.command !== (currentTool.command || "");
379
- const binUnchanged =
380
- nextTool.bin !== undefined && nextTool.bin === (currentTool.bin || "");
381
-
382
- if (commandChanged && binUnchanged) {
383
- merged.bin = "";
384
- }
379
+ }
385
380
 
386
- return merged;
381
+ // "" => 视作「该字段未修改」,从 patch 里删掉。null => 显式清空,保留。
382
+ // 仅扫一层(telegram / lark 的字段都在顶层,没有嵌套 string)。
383
+ function stripEmptyStrings(patch) {
384
+ if (!patch || typeof patch !== "object") return;
385
+ for (const key of Object.keys(patch)) {
386
+ if (patch[key] === "") delete patch[key];
387
+ }
387
388
  }
388
389
 
389
390
  function splitEditorPath(rawPath = "") {
@@ -598,6 +599,34 @@ export function createServer(opts = {}) {
598
599
  }
599
600
  });
600
601
 
602
+ // Claude stdout 提示词检测器命中 → 兜底 Notification hook 不 fire 的场景
603
+ // (settings.json permissions.defaultMode='auto' 时 model classifier 触发的权限框
604
+ // 实测不走 Notification hook)。复用 handleClaude 的 Notification 分支:
605
+ // markPendingConfirm + IM 推送都靠现有逻辑跑;与真 Notification 之间用 cooldown 去重。
606
+ pty.on("claude-prompt", async (data) => {
607
+ const port = runtimeConfig?.port || 5677;
608
+ // 显式 info 日志:detector 失火 vs Notification hook 失火很难肉眼区分,留个面包屑
609
+ // 方便定位"宝子又说没生效"那类问题(grep server.log "claude-prompt")。
610
+ console.log(`[claude-prompt] detector fired sid=${data.sessionId} opts=${(data.options || []).length} prompt=${(data.promptText || '').slice(0, 80).replace(/\n/g, ' ')}`);
611
+ try {
612
+ await fetch(`http://127.0.0.1:${port}/api/openclaw/hook`, {
613
+ method: "POST",
614
+ headers: { "content-type": "application/json" },
615
+ body: JSON.stringify({
616
+ source: "claude",
617
+ path: "detector",
618
+ event: "Notification",
619
+ sessionId: data.sessionId,
620
+ nativeId: data.nativeId,
621
+ promptText: data.promptText,
622
+ options: data.options,
623
+ }),
624
+ });
625
+ } catch (e) {
626
+ console.warn("[claude-prompt] post failed:", e.message);
627
+ }
628
+ });
629
+
601
630
  // Telegram 自动 topic 钩子:ait 创建在前,wizard 创建在后;用 lazy ref 桥接
602
631
  const aiSessionHooks = {
603
632
  onSessionSpawned: () => null,
@@ -649,6 +678,7 @@ export function createServer(opts = {}) {
649
678
 
650
679
  app.put("/api/config", async (req, res) => {
651
680
  try {
681
+ const result = await withConfigLock(async () => {
652
682
  const current = loadConfig({ rootDir: configRootDir });
653
683
  const nextToolsPatch = req.body?.tools || {};
654
684
  const pricingPatch = req.body?.pricing;
@@ -669,6 +699,12 @@ export function createServer(opts = {}) {
669
699
  // botTokenMasked / botTokenSource 是 GET-only,PUT 收到的不能写回
670
700
  delete telegramPatch.botTokenMasked;
671
701
  delete telegramPatch.botTokenSource;
702
+ // 防御性 guard:任何 string 字段为 '' 都视为「保留磁盘原值」。
703
+ // Drawer 在 form 未就绪时会把整段 telegram/lark 都用 '' 兜底;
704
+ // 没有这层保护就会把磁盘上现存的 appId / supergroupId / chatId 等清空,
705
+ // 然后每次保存都延续空值,造成「偶尔丢、丢了之后回不来」。
706
+ // 显式清空请用 null。
707
+ stripEmptyStrings(telegramPatch);
672
708
 
673
709
  // 合并 telegram / lark 段
674
710
  const mergedTelegram = { ...current.telegram, ...telegramPatch };
@@ -681,6 +717,7 @@ export function createServer(opts = {}) {
681
717
  }
682
718
  delete larkPatch.appSecretMasked;
683
719
  delete larkPatch.appSecretSource;
720
+ stripEmptyStrings(larkPatch);
684
721
  const mergedLark = { ...current.lark, ...larkPatch };
685
722
 
686
723
  // 检测 bot 段是否变化(用于触发热重启)
@@ -751,7 +788,7 @@ export function createServer(opts = {}) {
751
788
  const { token, source } = readBotTokenWithSource(() => reloadedCfg);
752
789
  const { botToken: _drop, ...telegramSafe } = reloadedCfg.telegram || {};
753
790
 
754
- res.json({
791
+ return {
755
792
  ok: true,
756
793
  config: {
757
794
  ...reloadedCfg,
@@ -769,7 +806,9 @@ export function createServer(opts = {}) {
769
806
  larkRestart,
770
807
  },
771
808
  telegramRestart,
809
+ };
772
810
  });
811
+ res.json(result);
773
812
  } catch (e) {
774
813
  res.status(500).json({ ok: false, error: e.message });
775
814
  }
@@ -1194,6 +1233,30 @@ export function createServer(opts = {}) {
1194
1233
  // OpenClaw 双向桥接:bridge(出站)+ pending-question 协调器(双向阻塞)
1195
1234
  const openclawBridge = createOpenClawBridge({
1196
1235
  getConfig: () => loadConfig({ rootDir: configRootDir }),
1236
+ getRoutesForSession: (sessionId) => {
1237
+ if (!sessionId) return { telegram: null, lark: null }
1238
+ // 优先用 in-memory session 拿 todoId(O(1)),失败再 fallback listTodos 全扫
1239
+ let todoId = null
1240
+ try {
1241
+ const sess = ait?.sessions?.get?.(sessionId)
1242
+ todoId = sess?.todoId || null
1243
+ } catch { /* ignore */ }
1244
+ if (todoId) {
1245
+ try {
1246
+ const todo = db.getTodo?.(todoId)
1247
+ const ai = (todo?.aiSessions || []).find(s => s?.sessionId === sessionId)
1248
+ if (ai) return { telegram: ai.telegramRoute || null, lark: ai.larkRoute || null }
1249
+ } catch { /* fallthrough */ }
1250
+ }
1251
+ try {
1252
+ const todos = db.listTodos?.({ status: 'all', archived: 'all' }) || []
1253
+ for (const t of todos) {
1254
+ const ai = (t.aiSessions || []).find(s => s?.sessionId === sessionId)
1255
+ if (ai) return { telegram: ai.telegramRoute || null, lark: ai.larkRoute || null }
1256
+ }
1257
+ } catch { /* ignore */ }
1258
+ return { telegram: null, lark: null }
1259
+ },
1197
1260
  });
1198
1261
  const pendingCoord = createPendingQuestionCoordinator({ db });
1199
1262
  pendingCoord.start();
@@ -1302,6 +1365,11 @@ export function createServer(opts = {}) {
1302
1365
  getConfig: () => loadConfig({ rootDir: configRootDir }),
1303
1366
  wizard: {
1304
1367
  handleInbound: (...args) => openclawWizardLazyRef.handleInbound(...args),
1368
+ // 历史 bug:早先这里只挂了 handleInbound,导致 lark-bot.handleCardAction
1369
+ // 调 wizard.handleCallback 时 typeof !== 'function' → 所有飞书卡片按钮
1370
+ // click 一律被 drop,Lark UI 弹「服务未就绪」。telegram 注入路径(上方
1371
+ // createTelegramBot 那段)一直有这条,本次对齐补全。
1372
+ handleCallback: (...args) => openclawWizardLazyRef.handleCallback(...args),
1305
1373
  },
1306
1374
  logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
1307
1375
  })
@@ -1348,8 +1416,8 @@ export function createServer(opts = {}) {
1348
1416
  '回 esc / 退出菜单 → 我帮你按 Esc 退出 modal',
1349
1417
  '回 中断 / ctrl+c → 我帮你打断当前任务',
1350
1418
  ].join('\n')
1351
- openclawBridge.postText({ sessionId, message })
1352
- .catch((e) => console.warn(`[tui-detected] postText failed: ${e.message}`))
1419
+ openclawBridge.broadcastText({ sessionId, message })
1420
+ .catch((e) => console.warn(`[tui-detected] broadcastText failed: ${e.message}`))
1353
1421
  })
1354
1422
 
1355
1423
  // holder Proxy: 让 hook / wizard 不用改源码,每次读属性都从 holder.current 拿最新实例
@@ -1410,9 +1478,9 @@ export function createServer(opts = {}) {
1410
1478
  onStale: async ({ sessionId, queueSize }) => {
1411
1479
  const text = `⚠️ session 有 ${queueSize} 条排队消息超过 5 分钟未投递,看起来卡住了。可发送 \`!!\` 中断后重新发送。`
1412
1480
  try {
1413
- await openclawBridge?.postText?.({ sessionId, message: text })
1481
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1414
1482
  } catch (e) {
1415
- console.warn(`[server] dispatcher.onStale postText failed: ${e.message}`)
1483
+ console.warn(`[server] dispatcher.onStale broadcastText failed: ${e.message}`)
1416
1484
  }
1417
1485
  },
1418
1486
  onSessionEnd: async ({ sessionId, undeliveredCount, undeliveredTexts }) => {
@@ -1421,9 +1489,9 @@ export function createServer(opts = {}) {
1421
1489
  const more = undeliveredCount > 3 ? `\n(还有 ${undeliveredCount - 3} 条未列出)` : ''
1422
1490
  const text = `⚠️ session 已结束,未投递 ${undeliveredCount} 条消息:\n${preview}${more}`
1423
1491
  try {
1424
- await openclawBridge?.postText?.({ sessionId, message: text })
1492
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1425
1493
  } catch (e) {
1426
- console.warn(`[server] dispatcher.onSessionEnd postText failed: ${e.message}`)
1494
+ console.warn(`[server] dispatcher.onSessionEnd broadcastText failed: ${e.message}`)
1427
1495
  }
1428
1496
  },
1429
1497
  },
@@ -1508,7 +1576,7 @@ export function createServer(opts = {}) {
1508
1576
  console.log(`[server] skip auto-close: sid=${sessionId} exit=${exitCode} lifetime=${lifetimeMs}ms`)
1509
1577
  return null
1510
1578
  }
1511
- const route = openclawBridge.resolveRoute?.(sessionId)
1579
+ const route = openclawBridge.resolveRoute?.(sessionId, 'telegram')
1512
1580
  if (!route?.threadId) return null
1513
1581
  return openclawWizard.handleTopicEvent({
1514
1582
  type: 'closed',
@@ -1529,14 +1597,15 @@ export function createServer(opts = {}) {
1529
1597
  let sweptLark = 0
1530
1598
  for (const [sid, sess] of ait.sessions) {
1531
1599
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1532
- const r = openclawBridge.resolveRoute?.(sid)
1533
- if (tgSweep && !r?.threadId) {
1600
+ const rTg = tgSweep ? openclawBridge.resolveRoute?.(sid, 'telegram') : null
1601
+ if (tgSweep && !rTg?.threadId) {
1534
1602
  openclawWizard.ensureTopicForSession({ sessionId: sid, todoId: sess.todoId })
1535
1603
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → telegram thread ${res.threadId}`))
1536
1604
  .catch((e) => console.warn(`[server] sweep ensureTopic failed for ${sid}: ${e.message}`))
1537
1605
  sweptTg++
1538
1606
  }
1539
- if (larkSweep && !(r?.channel === 'lark' && r?.rootMessageId)) {
1607
+ const rLark = larkSweep ? openclawBridge.resolveRoute?.(sid, 'lark') : null
1608
+ if (larkSweep && !rLark?.rootMessageId) {
1540
1609
  openclawWizard.ensureLarkThreadForSession({ sessionId: sid, todoId: sess.todoId })
1541
1610
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → lark root ${res.rootMessageId}`))
1542
1611
  .catch((e) => console.warn(`[server] sweep ensureLarkThread failed for ${sid}: ${e.message}`))
@@ -1581,7 +1650,7 @@ export function createServer(opts = {}) {
1581
1650
  let kicked = 0
1582
1651
  for (const [sid, sess] of ait.sessions) {
1583
1652
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1584
- const r = openclawBridge.resolveRoute?.(sid)
1653
+ const r = openclawBridge.resolveRoute?.(sid, 'telegram')
1585
1654
  if (!r?.threadId) continue
1586
1655
  if (tracker.has(sid)) continue
1587
1656
  tracker.start({ sessionId: sid, skipTitleRename: true })