agentquad 0.4.5 → 0.4.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.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/dist-web/assets/index-DdqC2CwH.css +32 -0
  3. package/dist-web/assets/{index-By--XlP3.js → index-DkI6ZJx_.js} +411 -399
  4. package/dist-web/assets/logo-Cxw7XzHl.png +0 -0
  5. package/dist-web/favicon.png +0 -0
  6. package/dist-web/index.html +2 -2
  7. package/package.json +7 -1
  8. package/src/claude-prompt-detector.js +72 -0
  9. package/src/cli.js +1 -1
  10. package/src/codex-hook-installer.js +1 -1
  11. package/src/codex-prompt-detector.js +104 -13
  12. package/src/config.js +33 -5
  13. package/src/db.js +77 -31
  14. package/src/export/todoMarkdown.js +1 -9
  15. package/src/lark-bot.js +44 -5
  16. package/src/mcp/tools/destructive/index.js +22 -16
  17. package/src/mcp/tools/openclaw/index.js +12 -16
  18. package/src/mcp/tools/read/index.js +7 -7
  19. package/src/mcp/tools/write/index.js +9 -6
  20. package/src/openclaw-bridge.js +176 -28
  21. package/src/openclaw-hook-installer.js +2 -1
  22. package/src/openclaw-hook.js +127 -9
  23. package/src/openclaw-wizard.js +168 -191
  24. package/src/permission-prompt.js +113 -31
  25. package/src/prompt-render.js +0 -8
  26. package/src/pty.js +183 -49
  27. package/src/routes/ai-terminal.js +90 -26
  28. package/src/routes/telegram-sync.js +7 -5
  29. package/src/routes/todos.js +8 -14
  30. package/src/server.js +90 -12
  31. package/src/session-input-dispatcher.js +48 -4
  32. package/src/stats/report.js +1 -6
  33. package/src/telegram-bot.js +82 -15
  34. package/src/telegram-loading-status.js +1 -1
  35. package/src/templates/claude-hooks/notify.js +1 -1
  36. package/src/templates/codex-hooks/notify.js +1 -1
  37. package/src/wiki/index.js +1 -1
  38. package/src/wiki/sources.js +0 -1
  39. package/dist-web/assets/index-8A0oLLcX.css +0 -32
  40. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
@@ -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 拿到值。
@@ -645,6 +667,20 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
645
667
  // resume 路径上面已经 set 过;新会话首次得到 nativeId 时补一次。
646
668
  nativeSessionMap.set(`${tool}:${presetNativeId}`, sessionId)
647
669
  }
670
+ // Agent 身份快照:派活那一刻 todo.appliedTemplateIds[0] 就是这次的 agent。
671
+ // 写到 session 上,UI 不用反查 templates、用户改 todo agent 也不会改写历史会话归属。
672
+ let agentTemplateId = null
673
+ let agentName = null
674
+ const firstTemplateId = (todo.appliedTemplateIds || [])[0] || null
675
+ if (firstTemplateId) {
676
+ try {
677
+ const tpl = db.getTemplate(firstTemplateId)
678
+ if (tpl) {
679
+ agentTemplateId = tpl.id
680
+ agentName = tpl.name
681
+ }
682
+ } catch { /* 模板查不到就当无 agent */ }
683
+ }
648
684
  // 3. 一次性把 nativeSessionId 写进 DB(搬进 try 内:失败时不留脏 DB)。
649
685
  db.updateTodo(todoId, {
650
686
  status: 'ai_running',
@@ -658,21 +694,24 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
658
694
  completedAt: null,
659
695
  prompt,
660
696
  permissionMode: effectivePermissionMode,
697
+ ...(agentTemplateId ? { agentTemplateId, agentName } : {}),
661
698
  ...(label ? { label } : {}),
662
699
  }),
663
700
  })
664
- // 4. 5s 兜底:前端如果一直没发合法 init(极少见 — /exec 返回后 WS 还没连上),
701
+ // 4. 30s 兜底:前端如果一直没发合法 init(极少见 — 旧版本前端 / 网络真的挂了),
665
702
  // 用老的 80×24 兜底 spawn,避免 session 永远卡在 create 状态。
703
+ // 30s(不是 5s)的理由:新前端在隐藏挂载时会延迟到 IO 可见才发 init,主人停留在
704
+ // conversation tab 默认体验里也不应该撞兜底;30s 既给足切换窗口、又保留挂掉时的退路。
666
705
  session.spawnFallbackTimer = setTimeout(() => {
667
706
  session.spawnFallbackTimer = null
668
707
  if (session.spawned) return
669
- console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 5s)`)
708
+ console.warn(`[ai-terminal] spawn fallback fired session=${sessionId} (no init within 30s)`)
670
709
  session.spawned = true
671
710
  pty.startWithSize(sessionId, 80, 24).catch((e) => {
672
711
  console.warn(`[ai-terminal] spawn fallback failed: ${e.message}`)
673
712
  session.spawned = false
674
713
  })
675
- }, 5000)
714
+ }, 30000)
676
715
  session.spawnFallbackTimer.unref?.()
677
716
  } catch (error) {
678
717
  sessions.delete(sessionId)
@@ -762,6 +801,9 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
762
801
  outputBytesTotal: s.outputBytesTotal || 0,
763
802
  awaitingReply: !!s.awaitingReply,
764
803
  permissionPrompt: s.permissionPrompt || null,
804
+ // s 是 route 自己的 sessions Map 里的对象;usage 是 PtyManager 的 watcher
805
+ // 写在它自己 sessions Map 的对象上,必须显式跨读,不能直接 s.usage。
806
+ usage: pty.getUsage(sessionId),
765
807
  })
766
808
  }
767
809
  res.json({ ok: true, sessions: out })
@@ -1058,20 +1100,27 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1058
1100
  session.autoMode = nextAutoMode
1059
1101
  broadcastToSession(session, { type: 'auto_mode', autoMode: session.autoMode || null })
1060
1102
 
1061
- if (nextAutoMode !== 'bypass' || session.tool !== 'claude') return
1103
+ // 对称重启:claude/codex/cursor 三家 CLI permission mode 都是启动参数注入,
1104
+ // 运行时切换必须重启 PTY 才能让新 mode 真正生效。只要前后 effective mode 不同就重启;
1105
+ // 相同 / 工具不支持 / 没有 nativeSessionId 时退路(broadcast / 软通知)。
1106
+ const prevEffective = session.permissionMode || 'default'
1107
+ const nextEffective = nextAutoMode || 'default'
1108
+ if (prevEffective === nextEffective) return
1109
+ const RESTARTABLE_TOOLS = new Set(['claude', 'codex', 'cursor'])
1110
+ if (!RESTARTABLE_TOOLS.has(session.tool)) return
1062
1111
 
1063
1112
  if (!session.nativeSessionId) {
1064
1113
  sendToBrowser(ws, {
1065
1114
  type: 'auto_mode_notice',
1066
- autoMode: 'bypass',
1115
+ autoMode: nextAutoMode,
1067
1116
  immediate: false,
1068
1117
  reason: 'native_session_missing',
1069
- message: '当前 Claude 会话尚未拿到原生 session id,全托管将仅对后续启动/恢复的会话生效。',
1118
+ message: '当前会话尚未拿到原生 session id,模式切换将仅对后续启动/恢复的会话生效。',
1070
1119
  })
1071
1120
  return
1072
1121
  }
1073
1122
 
1074
- broadcastToSession(session, { type: 'auto_mode_switching', target: 'bypass' })
1123
+ broadcastToSession(session, { type: 'auto_mode_switching', target: nextEffective })
1075
1124
  const todoSnapshot = db.getTodo(session.todoId)
1076
1125
  session.replacedBySessionId = '__pending__'
1077
1126
  let restarted
@@ -1082,29 +1131,44 @@ export function createAiTerminal({ db, pty, logDir, defaultCwd, getDefaultCwd, o
1082
1131
  tool: session.tool,
1083
1132
  cwd: session.cwd || undefined,
1084
1133
  resumeNativeId: session.nativeSessionId,
1085
- permissionMode: 'bypass',
1086
- label: 'runtime:bypass',
1134
+ permissionMode: nextEffective,
1135
+ label: `runtime:${nextEffective}`,
1087
1136
  skipTelegram: true,
1088
1137
  ignoreExistingNativeSessionId: true,
1138
+ // 老 PTY 即将被 kill,新的 claude --resume 只是接管 jsonl —— 没有真跑新一轮。
1139
+ // 让 watcher 吃掉第一帧 stale 状态,避免它把刚翻成 idle 的状态再翻回 running。
1140
+ suppressStaleTurnDetect: true,
1089
1141
  })
1090
1142
  } catch (e) {
1091
1143
  delete session.replacedBySessionId
1092
1144
  restoreSessionAsCurrent(session, todoSnapshot)
1093
1145
  sendToBrowser(ws, {
1094
1146
  type: 'auto_mode_notice',
1095
- autoMode: 'bypass',
1147
+ autoMode: nextAutoMode,
1096
1148
  immediate: false,
1097
1149
  reason: 'restart_failed',
1098
- message: `切换全托管失败:${e.message}`,
1150
+ message: `切换托管模式失败:${e.message}`,
1099
1151
  })
1100
1152
  return
1101
1153
  }
1102
1154
 
1155
+ // 把新 session 翻到 idle:托管模式切换的语义就是"打断当前轮、换一套权限再起来",
1156
+ // 老 PTY 已经被 kill 在 mid-turn,新的 claude --resume 不会自动续上那一轮 ——
1157
+ // 它只是接管 jsonl 等用户输入。spawnSession 默认 status='running' 适用于"首次启动 +
1158
+ // CLI 直接吃 prompt"的场景;resume-after-mode-switch 都应该是 idle。
1159
+ // RESTARTABLE_TOOLS (claude/codex/cursor) 一视同仁——三家都是"resume 不续轮"的语义。
1160
+ const newSession = sessions.get(restarted.sessionId)
1161
+ if (newSession && LIVE_AI_STATUSES.has(newSession.status)) {
1162
+ newSession.status = 'idle'
1163
+ newSession.awaitingReply = true
1164
+ newSession.lastTurnDoneAt = Date.now()
1165
+ persistLiveSessionState(newSession, 'idle', 'ai_running', { lastTurnDoneAt: newSession.lastTurnDoneAt })
1166
+ }
1103
1167
  broadcastToSession(session, {
1104
1168
  type: 'session_restarted',
1105
1169
  oldSessionId: sessionId,
1106
1170
  newSessionId: restarted.sessionId,
1107
- autoMode: 'bypass',
1171
+ autoMode: nextEffective,
1108
1172
  })
1109
1173
  if (restarted.sessionId !== sessionId) {
1110
1174
  session.replacedBySessionId = restarted.sessionId
@@ -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',
@@ -13,8 +13,9 @@ export function createTodosRouter({ db, logDir, getPricing, getTools, getLiveSes
13
13
  router.get('/', (req, res) => {
14
14
  try {
15
15
  try { db.sweepRecurring(Date.now()) } catch (e) { console.warn('[sweepRecurring]', e?.message) }
16
- const { quadrant, status, keyword } = req.query
17
- const list = db.listTodos({ quadrant, status, keyword })
16
+ // quadrant 入参已退役(仍接受以兼容旧客户端,但不再过滤)
17
+ const { status, keyword } = req.query
18
+ const list = db.listTodos({ status, keyword })
18
19
  res.json({ ok: true, list })
19
20
  } catch (e) {
20
21
  res.status(500).json({ ok: false, error: e.message })
@@ -23,7 +24,7 @@ export function createTodosRouter({ db, logDir, getPricing, getTools, getLiveSes
23
24
 
24
25
  router.post('/', (req, res) => {
25
26
  try {
26
- const { title, description, quadrant, dueDate, workDir, brainstorm, appliedTemplateIds, parentId } = req.body || {}
27
+ const { title, description, dueDate, workDir, brainstorm, appliedTemplateIds, parentId } = req.body || {}
27
28
  if (!title || typeof title !== 'string') {
28
29
  res.status(400).json({ ok: false, error: 'missing title' })
29
30
  return
@@ -37,15 +38,10 @@ export function createTodosRouter({ db, logDir, getPricing, getTools, getLiveSes
37
38
  res.status(400).json({ ok: false, error: 'nested_subtodo_not_allowed' })
38
39
  return
39
40
  }
40
- const q = parent ? parent.quadrant : (Number(quadrant) || 4)
41
- if (![1, 2, 3, 4].includes(q)) {
42
- res.status(400).json({ ok: false, error: 'invalid quadrant' })
43
- return
44
- }
41
+ // quadrant 已退役 —— 不再透传,让 db 层用默认值
45
42
  const todo = db.createTodo({
46
43
  title,
47
44
  description: description || '',
48
- quadrant: q,
49
45
  dueDate: dueDate ?? null,
50
46
  workDir: workDir || null,
51
47
  brainstorm: !!brainstorm,
@@ -100,11 +96,9 @@ export function createTodosRouter({ db, logDir, getPricing, getTools, getLiveSes
100
96
  res.status(400).json({ ok: false, error: 'nested_subtodo_not_allowed' })
101
97
  return
102
98
  }
103
- if (parent && patch.quadrant !== undefined && Number(patch.quadrant) !== parent.quadrant) {
104
- res.status(400).json({ ok: false, error: 'parent_quadrant_mismatch' })
105
- return
106
- }
107
- const todo = db.updateTodo(req.params.id, patch)
99
+ // quadrant 入参已退役:剥掉避免传到 db 层(db 仍接受但 UI 不再使用)
100
+ const { quadrant: _ignoredQuadrant, ...sanitizedPatch } = patch
101
+ const todo = db.updateTodo(req.params.id, sanitizedPatch)
108
102
 
109
103
  // Auto-close PTY when status transitions to 'done':
110
104
  // - kill all live AI sessions of this todo and its subtodos
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";
@@ -377,6 +378,15 @@ function mergeToolConfig(currentTool = {}, nextTool = {}) {
377
378
  };
378
379
  }
379
380
 
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
+ }
388
+ }
389
+
380
390
  function splitEditorPath(rawPath = "") {
381
391
  const trimmed = String(rawPath || "").trim();
382
392
  const match = trimmed.match(/^(.*?)(:\d+(?::\d+)?)?$/);
@@ -589,6 +599,34 @@ export function createServer(opts = {}) {
589
599
  }
590
600
  });
591
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
+
592
630
  // Telegram 自动 topic 钩子:ait 创建在前,wizard 创建在后;用 lazy ref 桥接
593
631
  const aiSessionHooks = {
594
632
  onSessionSpawned: () => null,
@@ -640,6 +678,7 @@ export function createServer(opts = {}) {
640
678
 
641
679
  app.put("/api/config", async (req, res) => {
642
680
  try {
681
+ const result = await withConfigLock(async () => {
643
682
  const current = loadConfig({ rootDir: configRootDir });
644
683
  const nextToolsPatch = req.body?.tools || {};
645
684
  const pricingPatch = req.body?.pricing;
@@ -660,6 +699,12 @@ export function createServer(opts = {}) {
660
699
  // botTokenMasked / botTokenSource 是 GET-only,PUT 收到的不能写回
661
700
  delete telegramPatch.botTokenMasked;
662
701
  delete telegramPatch.botTokenSource;
702
+ // 防御性 guard:任何 string 字段为 '' 都视为「保留磁盘原值」。
703
+ // Drawer 在 form 未就绪时会把整段 telegram/lark 都用 '' 兜底;
704
+ // 没有这层保护就会把磁盘上现存的 appId / supergroupId / chatId 等清空,
705
+ // 然后每次保存都延续空值,造成「偶尔丢、丢了之后回不来」。
706
+ // 显式清空请用 null。
707
+ stripEmptyStrings(telegramPatch);
663
708
 
664
709
  // 合并 telegram / lark 段
665
710
  const mergedTelegram = { ...current.telegram, ...telegramPatch };
@@ -672,6 +717,7 @@ export function createServer(opts = {}) {
672
717
  }
673
718
  delete larkPatch.appSecretMasked;
674
719
  delete larkPatch.appSecretSource;
720
+ stripEmptyStrings(larkPatch);
675
721
  const mergedLark = { ...current.lark, ...larkPatch };
676
722
 
677
723
  // 检测 bot 段是否变化(用于触发热重启)
@@ -742,7 +788,7 @@ export function createServer(opts = {}) {
742
788
  const { token, source } = readBotTokenWithSource(() => reloadedCfg);
743
789
  const { botToken: _drop, ...telegramSafe } = reloadedCfg.telegram || {};
744
790
 
745
- res.json({
791
+ return {
746
792
  ok: true,
747
793
  config: {
748
794
  ...reloadedCfg,
@@ -760,7 +806,9 @@ export function createServer(opts = {}) {
760
806
  larkRestart,
761
807
  },
762
808
  telegramRestart,
809
+ };
763
810
  });
811
+ res.json(result);
764
812
  } catch (e) {
765
813
  res.status(500).json({ ok: false, error: e.message });
766
814
  }
@@ -1185,6 +1233,30 @@ export function createServer(opts = {}) {
1185
1233
  // OpenClaw 双向桥接:bridge(出站)+ pending-question 协调器(双向阻塞)
1186
1234
  const openclawBridge = createOpenClawBridge({
1187
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
+ },
1188
1260
  });
1189
1261
  const pendingCoord = createPendingQuestionCoordinator({ db });
1190
1262
  pendingCoord.start();
@@ -1293,6 +1365,11 @@ export function createServer(opts = {}) {
1293
1365
  getConfig: () => loadConfig({ rootDir: configRootDir }),
1294
1366
  wizard: {
1295
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),
1296
1373
  },
1297
1374
  logger: { warn: (...a) => console.warn(...a), info: (...a) => console.log(...a) },
1298
1375
  })
@@ -1339,8 +1416,8 @@ export function createServer(opts = {}) {
1339
1416
  '回 esc / 退出菜单 → 我帮你按 Esc 退出 modal',
1340
1417
  '回 中断 / ctrl+c → 我帮你打断当前任务',
1341
1418
  ].join('\n')
1342
- openclawBridge.postText({ sessionId, message })
1343
- .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}`))
1344
1421
  })
1345
1422
 
1346
1423
  // holder Proxy: 让 hook / wizard 不用改源码,每次读属性都从 holder.current 拿最新实例
@@ -1401,9 +1478,9 @@ export function createServer(opts = {}) {
1401
1478
  onStale: async ({ sessionId, queueSize }) => {
1402
1479
  const text = `⚠️ session 有 ${queueSize} 条排队消息超过 5 分钟未投递,看起来卡住了。可发送 \`!!\` 中断后重新发送。`
1403
1480
  try {
1404
- await openclawBridge?.postText?.({ sessionId, message: text })
1481
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1405
1482
  } catch (e) {
1406
- console.warn(`[server] dispatcher.onStale postText failed: ${e.message}`)
1483
+ console.warn(`[server] dispatcher.onStale broadcastText failed: ${e.message}`)
1407
1484
  }
1408
1485
  },
1409
1486
  onSessionEnd: async ({ sessionId, undeliveredCount, undeliveredTexts }) => {
@@ -1412,9 +1489,9 @@ export function createServer(opts = {}) {
1412
1489
  const more = undeliveredCount > 3 ? `\n(还有 ${undeliveredCount - 3} 条未列出)` : ''
1413
1490
  const text = `⚠️ session 已结束,未投递 ${undeliveredCount} 条消息:\n${preview}${more}`
1414
1491
  try {
1415
- await openclawBridge?.postText?.({ sessionId, message: text })
1492
+ await openclawBridge?.broadcastText?.({ sessionId, message: text })
1416
1493
  } catch (e) {
1417
- console.warn(`[server] dispatcher.onSessionEnd postText failed: ${e.message}`)
1494
+ console.warn(`[server] dispatcher.onSessionEnd broadcastText failed: ${e.message}`)
1418
1495
  }
1419
1496
  },
1420
1497
  },
@@ -1499,7 +1576,7 @@ export function createServer(opts = {}) {
1499
1576
  console.log(`[server] skip auto-close: sid=${sessionId} exit=${exitCode} lifetime=${lifetimeMs}ms`)
1500
1577
  return null
1501
1578
  }
1502
- const route = openclawBridge.resolveRoute?.(sessionId)
1579
+ const route = openclawBridge.resolveRoute?.(sessionId, 'telegram')
1503
1580
  if (!route?.threadId) return null
1504
1581
  return openclawWizard.handleTopicEvent({
1505
1582
  type: 'closed',
@@ -1520,14 +1597,15 @@ export function createServer(opts = {}) {
1520
1597
  let sweptLark = 0
1521
1598
  for (const [sid, sess] of ait.sessions) {
1522
1599
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1523
- const r = openclawBridge.resolveRoute?.(sid)
1524
- if (tgSweep && !r?.threadId) {
1600
+ const rTg = tgSweep ? openclawBridge.resolveRoute?.(sid, 'telegram') : null
1601
+ if (tgSweep && !rTg?.threadId) {
1525
1602
  openclawWizard.ensureTopicForSession({ sessionId: sid, todoId: sess.todoId })
1526
1603
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → telegram thread ${res.threadId}`))
1527
1604
  .catch((e) => console.warn(`[server] sweep ensureTopic failed for ${sid}: ${e.message}`))
1528
1605
  sweptTg++
1529
1606
  }
1530
- if (larkSweep && !(r?.channel === 'lark' && r?.rootMessageId)) {
1607
+ const rLark = larkSweep ? openclawBridge.resolveRoute?.(sid, 'lark') : null
1608
+ if (larkSweep && !rLark?.rootMessageId) {
1531
1609
  openclawWizard.ensureLarkThreadForSession({ sessionId: sid, todoId: sess.todoId })
1532
1610
  .then((res) => res?.action === 'created' && console.log(`[server] sweep auto-bound ${sid} → lark root ${res.rootMessageId}`))
1533
1611
  .catch((e) => console.warn(`[server] sweep ensureLarkThread failed for ${sid}: ${e.message}`))
@@ -1572,7 +1650,7 @@ export function createServer(opts = {}) {
1572
1650
  let kicked = 0
1573
1651
  for (const [sid, sess] of ait.sessions) {
1574
1652
  if (sess.status !== 'running' && sess.status !== 'idle' && sess.status !== 'pending_confirm') continue
1575
- const r = openclawBridge.resolveRoute?.(sid)
1653
+ const r = openclawBridge.resolveRoute?.(sid, 'telegram')
1576
1654
  if (!r?.threadId) continue
1577
1655
  if (tracker.has(sid)) continue
1578
1656
  tracker.start({ sessionId: sid, skipTitleRename: true })