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.
@@ -83,6 +83,9 @@ const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
83
83
  const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
84
84
  const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛┌┐└┘╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
85
85
  const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
86
+ // Unicode Block Elements (U+2580-259F):▀▁▂▃▄▅▆▇█ ▉▊▋▌▍▎▏ ▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟
87
+ // Cursor TUI 用这些画状态栏 / 进度条 / 边框,发到 IM 一串看就是黑条。
88
+ const BOX_BLOCK = /[▀-▟]/g
86
89
 
87
90
  function cleanBoxDrawing(s) {
88
91
  return String(s || '')
@@ -90,6 +93,7 @@ function cleanBoxDrawing(s) {
90
93
  .replace(BOX_VERTICAL, '|') // 竖线 → |
91
94
  .replace(BOX_CORNERS, '+') // 角 → +
92
95
  .replace(BOX_TEES, '+') // 三叉 → +
96
+ .replace(BOX_BLOCK, '') // 块元素直接删(连成一片就是黑条,没语义)
93
97
  }
94
98
 
95
99
  function compactBlankLines(s) {
@@ -114,6 +118,15 @@ const PROMPT_LINE = /^\s*(❯|⏵|►|→)/
114
118
  const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
115
119
  const BORDER_LINE = /^[\s\-=_|+~]+$/
116
120
 
121
+ // Cursor TUI 底部状态栏噪声 —— 每次 stop hook 触发时这些行都会出现在 PTY tail,
122
+ // 完全没信息量,发到 IM 是纯刷屏。
123
+ // "Opus 4.6 (Thinking) 200K High · 23.6%" → 模型选择器 + context %
124
+ // "Auto-run" / "Manual" → 模式指示
125
+ // "~/Desktop/code/crazyCombo/quadtodo · main" → cwd · git branch
126
+ const CURSOR_MODEL_LINE = /^\s*(Opus|Sonnet|Haiku|GPT|Claude|Codex|Composer)\s+[\w.-]+.*·\s*[\d.]+%\s*$/i
127
+ const CURSOR_AUTORUN_LINE = /^\s*(Auto-run|Manual|Auto)\s*$/i
128
+ const CURSOR_CWD_BRANCH_LINE = /^\s*~?\/[^\s·]+(?:\/[^\s·]+)*\s+·\s+[\w./-]+\s*$/
129
+
117
130
  function isSpinnerOnly(line) {
118
131
  // 全部是 spinner 字符(含空格)
119
132
  const trimmed = line.replace(/\s+/g, '')
@@ -130,6 +143,9 @@ function isStatusLine(line) {
130
143
  if (PROMPT_LINE.test(line)) return true
131
144
  if (AUTO_MODE_LINE.test(line)) return true
132
145
  if (BORDER_LINE.test(line)) return true
146
+ if (CURSOR_MODEL_LINE.test(line)) return true
147
+ if (CURSOR_AUTORUN_LINE.test(line)) return true
148
+ if (CURSOR_CWD_BRANCH_LINE.test(line)) return true
133
149
  return false
134
150
  }
135
151
 
@@ -444,6 +460,7 @@ export function createOpenClawHookHandler(deps = {}) {
444
460
  * 处理一条 hook 事件 —— 统一入口,按 source/path 分发。
445
461
  * - source=codex,path=jsonl → handleCodexJsonl(Phase C)
446
462
  * - source=codex,path=detector → handleCodexDetector(Phase E 占位,目前直接拒绝)
463
+ * - source=claude,path=detector→ handleClaudeDetector(PTY 兜底 Notification hook 不 fire)
447
464
  * - 其它(默认 claude) → handleClaude(保留原逻辑,签名不变)
448
465
  * 返回 { ok, action: 'sent'|'skipped'|'failed', reason? }
449
466
  */
@@ -451,6 +468,7 @@ export function createOpenClawHookHandler(deps = {}) {
451
468
  const source = req?.source || 'claude'
452
469
  if (source === 'codex' && req?.path === 'jsonl') return handleCodexJsonl(req)
453
470
  if (source === 'codex' && req?.path === 'detector') return handleCodexDetector(req)
471
+ if (source === 'claude' && req?.path === 'detector') return handleClaudeDetector(req)
454
472
  return handleClaude(req)
455
473
  }
456
474
 
@@ -566,12 +584,12 @@ export function createOpenClawHookHandler(deps = {}) {
566
584
  // bridge.postText 的形参是 `message`,不是 `text`——早期实现写错字段名,
567
585
  // 导致 bridge 收到 message=undefined 直接走 message_required 短路返回。
568
586
  try {
569
- const r = await codexBridge.postText({ sessionId: quadtodoSessionId, message: fullText })
587
+ const r = await codexBridge.broadcastText({ sessionId: quadtodoSessionId, message: fullText })
570
588
  if (!r?.ok) {
571
- logger.warn?.(`[codex-hook] postText returned not-ok: reason=${r?.reason} detail=${r?.detail || ''}`)
589
+ logger.warn?.(`[codex-hook] broadcastText returned not-ok: reason=${r?.reason} detail=${r?.detail || ''}`)
572
590
  return { ok: false, reason: 'post_failed', detail: r?.reason }
573
591
  }
574
- logger.info?.(`[codex-hook] postText OK sessionId=${quadtodoSessionId} event=${event} len=${fullText.length}`)
592
+ logger.info?.(`[codex-hook] broadcastText OK sessionId=${quadtodoSessionId} event=${event} len=${fullText.length}`)
575
593
  } catch (e) {
576
594
  logger.warn?.(`[codex-hook] postText threw: ${e.message}`)
577
595
  return { ok: false, reason: 'post_failed', detail: e?.message }
@@ -628,6 +646,61 @@ export function createOpenClawHookHandler(deps = {}) {
628
646
  return { ok: true, action: 'sent', source: 'codex', event, nativeId, matchedPattern }
629
647
  }
630
648
 
649
+ // ─── Claude PTY-detector 分支 ────────────────────────────────────────────────
650
+ // Notification hook 不 fire 的兜底(实测 permissions.defaultMode='auto' 时 model
651
+ // classifier 触发的权限框走不到 hook)。两件事:
652
+ // 1) 翻 session.status → pending_confirm(web 端 deriveAiState 据此显示"待确认")
653
+ // 2) 推 IM 权限卡 / 按钮(跟真 Notification 走同一份 cooldown,避免双推)
654
+ // 与 handleCodexDetector 同构,但走 broadcastText(保持跟 Claude 真 Notification 一致),
655
+ // header 标题区别成 "Claude Code 等待你的响应"。
656
+ async function handleClaudeDetector({ event, sessionId, promptText } = {}) {
657
+ if (!sessionId) return { ok: false, reason: 'no_sessionId' }
658
+ const sess = aiTerminal?.sessions?.get(sessionId)
659
+ if (!sess) return { ok: false, reason: 'session_gone' }
660
+
661
+ // 1) 翻状态。markPendingConfirm 默认只接受 running → pending_confirm,但 PTY-detector
662
+ // 要求 anchor + ≥2 数字选项才 emit,假阳性概率极低;显式 allowIdleFlip=true 让它
663
+ // 在 status=idle 时也能翻(覆盖 auto 模式权限框在 Stop hook 后才浮出的场景)。
664
+ try {
665
+ aiTerminal?.markPendingConfirm?.(sessionId, {
666
+ source: 'claude-pty-detector',
667
+ promptText,
668
+ allowIdleFlip: true,
669
+ })
670
+ } catch { /* ignore */ }
671
+
672
+ // 2) IM 推送资格 & cooldown 跟真 Notification 共享 → 不会双推
673
+ if (!isPermissionReminderEligible(sessionId)) {
674
+ return { ok: true, action: 'skipped', reason: 'im_push_not_eligible' }
675
+ }
676
+ const cd = notificationCooldownMs()
677
+ if (cd > 0 && isOnCooldown(sessionId, 'notification', cd)) {
678
+ return { ok: true, action: 'skipped', reason: 'notification_cooldown', cooldownMs: cd }
679
+ }
680
+
681
+ // 3) 拼消息:直接用 PTY detector 抽出来的 promptText(已经过 anchor + cleanPtyTail 清洗)
682
+ const todoId = sess.todoId
683
+ let todoTitle = todoId
684
+ try {
685
+ const todo = await db.getTodo?.(todoId)
686
+ todoTitle = todo?.title || todoId
687
+ } catch { /* ignore */ }
688
+ const idTail = todoId ? String(todoId).slice(-3) : '???'
689
+ let message = `[#t${idTail}] 任务「${todoTitle}」\n\n${promptText || '(no prompt text)'}`
690
+ message = buildPermissionNotificationMessage(message)
691
+ const replyMarkup = buildPermissionReplyMarkup(sessionId)
692
+
693
+ let result
694
+ try {
695
+ result = await openclaw.broadcastText({ sessionId, message, replyMarkup })
696
+ } catch (e) {
697
+ logger.warn?.(`[claude-detector] broadcastText failed: ${e.message}`)
698
+ return { ok: false, reason: 'post_failed', detail: e?.message }
699
+ }
700
+ if (result?.ok !== false) recordSent(sessionId, 'notification')
701
+ return { ok: true, action: 'sent', source: 'claude', path: 'detector', event }
702
+ }
703
+
631
704
  // ─── Claude 分支(既有实现,原 handle() 主体不变)─────────────────────────────
632
705
  async function handleClaude({ event, sessionId, todoId, todoTitle, hookPayload } = {}) {
633
706
  if (!event) return { ok: false, action: 'failed', reason: 'event_required' }
@@ -642,11 +715,56 @@ export function createOpenClawHookHandler(deps = {}) {
642
715
  logger.warn?.(`[openclaw-hook] hook fired with no registered route: event=${evt} sid=${sessionId} todoId=${todoId || 'null'}`)
643
716
  }
644
717
 
718
+ // 0) user-prompt-submit → echo user's prompt to all IM channels (minus origin)
719
+ if (evt === 'user-prompt-submit') {
720
+ if (!sessionId) return { ok: true, action: 'skipped', reason: 'no_session' }
721
+ const promptRaw =
722
+ (hookPayload && typeof hookPayload === 'object' && (
723
+ hookPayload.user_prompt ||
724
+ hookPayload.prompt ||
725
+ hookPayload.user_message ||
726
+ hookPayload.message
727
+ )) || ''
728
+ const prompt = String(promptRaw).trim()
729
+ if (!prompt) return { ok: true, action: 'skipped', reason: 'empty_prompt' }
730
+
731
+ // 截断:>2000 字符 → 取前 2000 + 末尾标注总字数
732
+ const MAX = 2000
733
+ const truncated = prompt.length > MAX
734
+ ? `${prompt.slice(0, MAX)}\n… [共 ${prompt.length} 字]`
735
+ : prompt
736
+ const message = `👤 ${truncated}`
737
+
738
+ let originChannel = null
739
+ try {
740
+ originChannel = sessionInputDispatcher?.consumeOrigin?.(sessionId, prompt) || null
741
+ } catch (e) {
742
+ logger.warn?.(`[openclaw-hook] consumeOrigin threw: ${e.message}`)
743
+ }
744
+
745
+ try {
746
+ await openclaw?.broadcastEcho?.({ sessionId, message, excludeChannel: originChannel })
747
+ } catch (e) {
748
+ logger.warn?.(`[openclaw-hook] broadcastEcho threw: ${e.message}`)
749
+ }
750
+ return { ok: true, action: 'echoed', origin: originChannel, length: prompt.length }
751
+ }
752
+
645
753
  // 1) ask_user pending 时 Stop 静默
646
754
  if (evt === 'stop' && hasPendingAskUser(sessionId)) {
647
755
  return { ok: true, action: 'skipped', reason: 'ask_user_pending' }
648
756
  }
649
757
 
758
+ // 1a) Cursor 的 stop hook 每轮 fire 3 次(实测 13ms 内连发,cursor 内部架构使然)。
759
+ // 给 cursor session 加 5 秒短 cooldown 去重 —— 5s 足够吞掉同一轮内的连发,
760
+ // 又远小于用户两轮对话的最小间隔。Claude/Codex 不受影响(它们 stop 一轮一发)。
761
+ if (evt === 'stop' && sessionId) {
762
+ const sess = aiTerminal?.sessions?.get?.(sessionId)
763
+ if (sess?.tool === 'cursor' && isOnCooldown(sessionId, 'stop', 5_000)) {
764
+ return { ok: true, action: 'skipped', reason: 'cursor_stop_dedup' }
765
+ }
766
+ }
767
+
650
768
  const permissionReminderEligible = evt === 'notification' && isPermissionReminderEligible(sessionId)
651
769
 
652
770
  // 注:原来这里会立即 notifyWebTurnDone。已经挪到 ③ 读完 JSONL 之后,
@@ -833,8 +951,8 @@ export function createOpenClawHookHandler(deps = {}) {
833
951
  // footer 永远附在最末尾(即使消息被截短到附件也要保留,让用户能看到费用)
834
952
  if (usageFooter) message = `${message}\n\n${usageFooter}`
835
953
 
836
- // 4) 推送(postText 接受可选 attachment)
837
- const result = await openclaw.postText({
954
+ // 4) 推送(broadcastText 扇出到 session 所有绑定 channel;接受可选 attachment)
955
+ const result = await openclaw.broadcastText({
838
956
  sessionId,
839
957
  message,
840
958
  attachment: attachmentPath, // bridge 转给 telegramBot.sendDocument
@@ -843,7 +961,7 @@ export function createOpenClawHookHandler(deps = {}) {
843
961
 
844
962
  // 5) SessionEnd 后处理:close topic + 改名 ✅ + 清状态
845
963
  if (evt === 'session-end') {
846
- const route = openclaw.resolveRoute?.(sessionId)
964
+ const route = openclaw.resolveRoute?.(sessionId, 'telegram')
847
965
  if (route?.threadId && telegramBot) {
848
966
  try {
849
967
  await telegramBot.closeForumTopic({ chatId: route.targetUserId, threadId: route.threadId })
@@ -904,14 +1022,14 @@ export function createOpenClawHookHandler(deps = {}) {
904
1022
  }
905
1023
  // Stop / session-end → 清掉 lark "在思考" reaction(如果是 lark route)
906
1024
  if ((evt === 'stop' || evt === 'session-end') && sessionId && larkBot?.clearReactionsForSession) {
907
- const route = openclaw.resolveRoute?.(sessionId)
1025
+ const route = openclaw.resolveRoute?.(sessionId, 'lark')
908
1026
  if (route?.channel === 'lark') {
909
1027
  larkBot.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] clearReactionsForSession failed: ${e.message}`))
910
1028
  }
911
1029
  }
912
1030
  // Stop / session-end → 清掉 telegram "✍" reaction(如果是 telegram route)
913
1031
  if ((evt === 'stop' || evt === 'session-end') && sessionId && reactionTracker?.clearReactionsForSession) {
914
- const route = openclaw.resolveRoute?.(sessionId)
1032
+ const route = openclaw.resolveRoute?.(sessionId, 'telegram')
915
1033
  if (route?.channel === 'telegram') {
916
1034
  reactionTracker.clearReactionsForSession(sessionId).catch((e) => logger.warn?.(`[openclaw-hook] tg clearReactionsForSession failed: ${e.message}`))
917
1035
  }
@@ -938,4 +1056,4 @@ export function createOpenClawHookHandler(deps = {}) {
938
1056
  }
939
1057
  }
940
1058
 
941
- export const __test__ = { buildMessage, shortTodoId }
1059
+ export const __test__ = { buildMessage, shortTodoId, extractTailSnippet }
@@ -821,9 +821,9 @@ export function createOpenClawWizard({
821
821
  if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
822
822
  if (!telegramBot?.createForumTopic) return { ok: false, reason: 'no_telegram_bot' }
823
823
 
824
- // 已有路由跳过
825
- const existing = openclaw?.resolveRoute?.(sessionId)
826
- if (existing && existing.threadId) return { ok: true, action: 'already_bound' }
824
+ // 已有 telegram 路由 跳过(per-channel resolveRoute;避免被 lark 路由误判)
825
+ const existing = openclaw?.resolveRoute?.(sessionId, 'telegram')
826
+ if (existing?.threadId) return { ok: true, action: 'already_bound' }
827
827
 
828
828
  // DB 里已持久化(rehydrate 时常见)→ 重注路由就行
829
829
  const todo = db.getTodo(todoId)
@@ -896,8 +896,9 @@ export function createOpenClawWizard({
896
896
  if (!sessionId || !todoId) return { ok: false, reason: 'missing_args' }
897
897
  if (!larkBot?.sendMessage) return { ok: false, reason: 'no_lark_bot' }
898
898
 
899
- const existing = openclaw?.resolveRoute?.(sessionId)
900
- if (existing?.channel === 'lark' && existing?.rootMessageId) {
899
+ // 显式取 lark 路由,避免被 telegram 路由误判
900
+ const existing = openclaw?.resolveRoute?.(sessionId, 'lark')
901
+ if (existing?.rootMessageId) {
901
902
  return { ok: true, action: 'already_bound' }
902
903
  }
903
904
 
@@ -916,7 +917,7 @@ export function createOpenClawWizard({
916
917
 
917
918
  const shortCode = String(todoId).replace(/[^a-zA-Z0-9]/g, '').slice(-4).toLowerCase() || 'auto'
918
919
  const title = (todo.title || `todo-${shortCode}`).slice(0, 96)
919
- const topicName = `#t${shortCode} ${title}`.slice(0, 128)
920
+ const topicName = title.slice(0, 128)
920
921
  const intro = [
921
922
  `${topicName}`,
922
923
  `AI 已启动(自动镜像 from web/CLI),后续输出会回复在这条消息的 thread 里。`,
@@ -1015,7 +1016,7 @@ export function createOpenClawWizard({
1015
1016
  const todo = db.getTodo(sess.todoId)
1016
1017
  if (todo) {
1017
1018
  // 构造一个假 aiSession(DB 里没持久化,能跑就行)
1018
- const route = openclaw.resolveRoute?.(sid) || {}
1019
+ const route = openclaw.resolveRoute?.(sid, 'telegram') || {}
1019
1020
  const fakeAi = {
1020
1021
  sessionId: sid,
1021
1022
  tool: sess.tool,
@@ -1168,7 +1169,7 @@ export function createOpenClawWizard({
1168
1169
  if (sess?.todoId) {
1169
1170
  const todo = db.getTodo(sess.todoId)
1170
1171
  if (todo) {
1171
- const route = openclaw.resolveRoute?.(sid) || {}
1172
+ const route = openclaw.resolveRoute?.(sid, 'lark') || {}
1172
1173
  const fakeAi = {
1173
1174
  sessionId: sid,
1174
1175
  tool: sess.tool,
@@ -1272,6 +1273,16 @@ export function createOpenClawWizard({
1272
1273
  const routeKey = makeRouteKey(channel, chatId, threadId)
1273
1274
  const isLarkThreadReply = channel === 'lark' && (threadId || rootMessageId)
1274
1275
 
1276
+ // 飞书无前缀建任务守门:channel + 配置 + 文本 + slash 守门
1277
+ // 调用方需自行加 newTaskGateOpen + targetSid 缺失等额外条件。
1278
+ function shouldLarkAutoCreate() {
1279
+ if (channel !== 'lark') return false
1280
+ if (getConfig?.()?.lark?.autoCreateTodo === false) return false
1281
+ if (!trimmed) return false
1282
+ if (/^\/[a-z][a-z0-9_]*\b/i.test(trimmed)) return false
1283
+ return true
1284
+ }
1285
+
1275
1286
  // Lark 任务话题/root 回复必须严格隔离到原始路由:不允许被全局 ask_user、新任务触发词、
1276
1287
  // lastPush 或单活跃 session 等模糊 fallback 消费,避免把群内任务线程回复送到不相关会话。
1277
1288
  //
@@ -1565,6 +1576,36 @@ export function createOpenClawWizard({
1565
1576
  }
1566
1577
  }
1567
1578
  if (targetSid && typeof targetSid === 'object' && targetSid.notFound) {
1579
+ // 未绑定 lark thread 的首条消息:默认起新建任务向导(受 autoCreateTodo 控制)
1580
+ if (newTaskGateOpen && shouldLarkAutoCreate()) {
1581
+ logger.info?.(`[wizard] lark auto-create from non-prefix text (unbound thread): chatId=${chatId} thread=${threadId || '-'} title="${trimmed.slice(0, 80)}"`)
1582
+ const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
1583
+ if (w.step === STEP_DONE) return await finalizeWizard(w)
1584
+ if (w.step === STEP_QUADRANT) {
1585
+ const p = buildQuadrantPrompt()
1586
+ return {
1587
+ reply: `任务: ${w.title}\n(目录已识别为 ${w.chosenWorkdir})\n\n${p.text}`,
1588
+ replyMarkup: p.replyMarkup,
1589
+ action: 'wizard_started',
1590
+ }
1591
+ }
1592
+ if (w.step === STEP_TEMPLATE) {
1593
+ const tpls = db.listTemplates()
1594
+ w.cachedTemplates = tpls
1595
+ const p = buildTemplatePrompt(tpls)
1596
+ return {
1597
+ reply: `任务: ${w.title}\n(目录+象限已识别)\n\n${p.text}`,
1598
+ replyMarkup: p.replyMarkup,
1599
+ action: 'wizard_started',
1600
+ }
1601
+ }
1602
+ const p = buildWorkdirPrompt(w.workdirOptions)
1603
+ return {
1604
+ reply: `任务: ${w.title}\n\n${p.text}`,
1605
+ replyMarkup: p.replyMarkup,
1606
+ action: 'wizard_started',
1607
+ }
1608
+ }
1568
1609
  return {
1569
1610
  reply: '没有找到对应运行中的任务',
1570
1611
  action: 'session_not_found',
@@ -1718,6 +1759,38 @@ export function createOpenClawWizard({
1718
1759
  }
1719
1760
  }
1720
1761
 
1762
+ // 5.5 飞书无前缀建任务兜底:lark + autoCreateTodo + step 5 没匹配任何 PTY target →
1763
+ // 把消息原文当 title 起 wizard。Telegram/微信/openclaw 不受影响(channel 守门)。
1764
+ if (newTaskGateOpen && shouldLarkAutoCreate()) {
1765
+ logger.info?.(`[wizard] lark auto-create from non-prefix text: chatId=${chatId} thread=${threadId || '-'} title="${trimmed.slice(0, 80)}"`)
1766
+ const w = startWizard({ channel, chatId, threadId, text: trimmed, messageId, rootMessageId, imagePaths, userId: fromUserId })
1767
+ if (w.step === STEP_DONE) return await finalizeWizard(w)
1768
+ if (w.step === STEP_QUADRANT) {
1769
+ const p = buildQuadrantPrompt()
1770
+ return {
1771
+ reply: `任务: ${w.title}\n(目录已识别为 ${w.chosenWorkdir})\n\n${p.text}`,
1772
+ replyMarkup: p.replyMarkup,
1773
+ action: 'wizard_started',
1774
+ }
1775
+ }
1776
+ if (w.step === STEP_TEMPLATE) {
1777
+ const tpls = db.listTemplates()
1778
+ w.cachedTemplates = tpls
1779
+ const p = buildTemplatePrompt(tpls)
1780
+ return {
1781
+ reply: `任务: ${w.title}\n(目录+象限已识别)\n\n${p.text}`,
1782
+ replyMarkup: p.replyMarkup,
1783
+ action: 'wizard_started',
1784
+ }
1785
+ }
1786
+ const p = buildWorkdirPrompt(w.workdirOptions)
1787
+ return {
1788
+ reply: `任务: ${w.title}\n\n${p.text}`,
1789
+ replyMarkup: p.replyMarkup,
1790
+ action: 'wizard_started',
1791
+ }
1792
+ }
1793
+
1721
1794
  // 6. fallback
1722
1795
  // General 频道里专门提示:保护 PTY 上下文不被污染
1723
1796
  if (isInGeneralOfSupergroup) {
@@ -1782,7 +1855,7 @@ export function createOpenClawWizard({
1782
1855
  // ── 权限按钮路径(qt:perm:<short>:allow|deny)──────────────────
1783
1856
  const permCb = parsePermissionCallback(callbackData)
1784
1857
  if (permCb) {
1785
- return handlePermissionCallback(permCb, { chatId, threadId })
1858
+ return handlePermissionCallback(permCb, { chatId, threadId, channel: args.channel || null })
1786
1859
  }
1787
1860
  if (callbackData.startsWith(`${CALLBACK_PREFIX}:${PERMISSION_CALLBACK_KIND}:`)) {
1788
1861
  return { toast: '无效的权限按钮', action: 'invalid', editOriginal: true }
@@ -1899,30 +1972,51 @@ export function createOpenClawWizard({
1899
1972
  }
1900
1973
 
1901
1974
  // ─── 权限按钮回调 ───────────────────────────────────────────────
1902
- async function handlePermissionCallback({ short, action } = {}, { chatId, threadId } = {}) {
1903
- const stale = () => ({
1904
- toast: '会话已结束',
1905
- reply: `⚠️ 会话已结束(#${short}),无法发送权限选择。`,
1906
- action: 'permission_session_stale',
1907
- editOriginal: true,
1908
- })
1975
+ async function handlePermissionCallback({ short, action } = {}, { chatId, threadId, channel = null } = {}) {
1976
+ const stale = (why) => {
1977
+ logger.warn?.(`[wizard/perm] stale short=${short} action=${action} channel=${channel || 'null'} chatId=${chatId || 'null'} threadId=${threadId || 'null'} reason=${why}`)
1978
+ return {
1979
+ toast: '会话已结束',
1980
+ reply: `⚠️ 会话已结束(#${short}),无法发送权限选择。`,
1981
+ action: 'permission_session_stale',
1982
+ editOriginal: true,
1983
+ }
1984
+ }
1909
1985
 
1910
1986
  const sid = openclaw?.findSessionByShortId?.(short) || null
1911
- if (!sid || !pty?.has?.(sid)) return stale()
1912
- const route = openclaw?.resolveRoute?.(sid) || null
1913
- const sameChat = route && String(route.targetUserId) === String(chatId)
1914
- const sameThread = (route?.threadId || null) === (threadId || null)
1987
+ if (!sid) return stale('no_sid_for_short')
1988
+ if (!pty?.has?.(sid)) return stale(`pty_no_session sid=${sid}`)
1989
+ // channel hint 关键:同一 session 经常同时绑 telegram + lark,resolveRoute(sid)
1990
+ // 不带 channel 时返回最后注册的 route(通常是 telegram),导致 lark 点击的 sameChat
1991
+ // 校验失败被误判为 stale。caller 已经知道点击来自哪个渠道,必须传过来。
1992
+ const route = openclaw?.resolveRoute?.(sid, channel) || null
1993
+ if (!route) return stale(`no_route sid=${sid} channel=${channel || 'null'}`)
1994
+ const sameChat = String(route.targetUserId) === String(chatId)
1995
+ // 飞书 card.action.trigger 事件实测经常不带 open_thread_id(即使卡片是在 thread
1996
+ // 里发的);用 sameThread 硬校验会把所有 lark click 卡成 stale。lark 渠道下
1997
+ // 信任 sameChat + shortid(已经锁到具体 session)就够了,threadId 只在 telegram
1998
+ // 渠道生效(topic 隔离才有意义)。
1999
+ const sameThread = route.channel === 'lark'
2000
+ ? true
2001
+ : (route.threadId || null) === (threadId || null)
1915
2002
  // 允许 telegram / lark 渠道的权限回调;老的"threadId 非空就算"留作 legacy 兜底。
1916
- const isRoutedChannel = route?.channel === 'telegram' || route?.channel === 'lark' || route?.threadId != null
1917
- if (!sameChat || !sameThread || !isRoutedChannel) return stale()
2003
+ const isRoutedChannel = route.channel === 'telegram' || route.channel === 'lark' || route.threadId != null
2004
+ if (!sameChat || !sameThread || !isRoutedChannel) {
2005
+ return stale(`route_mismatch sid=${sid} route=${JSON.stringify({channel: route.channel, targetUserId: route.targetUserId, threadId: route.threadId})} sameChat=${sameChat} sameThread=${sameThread} isRoutedChannel=${isRoutedChannel}`)
2006
+ }
1918
2007
 
1919
2008
  if (action === PERMISSION_ACTION_ALLOW) {
1920
2009
  try {
1921
2010
  pty.write(sid, '\r')
1922
2011
  } catch (e) {
1923
2012
  logger.warn?.(`[wizard] permission allow write failed: ${e.message}`)
1924
- return stale()
2013
+ return stale('write_failed_allow')
1925
2014
  }
2015
+ // Lark click 直写 PTY 绕过了 /api/ai-terminal/input 路径,web 端的
2016
+ // permissionPrompt + 卡片不会自动消失。手工调一下 awaitingReply(false) 让
2017
+ // ai-terminal 走 markSessionRunningAfterInput → 翻 running、清 permissionPrompt、
2018
+ // 广播 pending_cleared,前端 webview 那张"AI 等待授权"卡随之消失。
2019
+ try { aiTerminal?.markSessionAwaitingReply?.(sid, false) } catch { /* ignore */ }
1926
2020
  return {
1927
2021
  toast: '已发送 Enter',
1928
2022
  chosenLabel: '允许(Enter)',
@@ -1936,8 +2030,9 @@ export function createOpenClawWizard({
1936
2030
  pty.write(sid, '\x1b')
1937
2031
  } catch (e) {
1938
2032
  logger.warn?.(`[wizard] permission deny write failed: ${e.message}`)
1939
- return stale()
2033
+ return stale('write_failed_deny')
1940
2034
  }
2035
+ try { aiTerminal?.markSessionAwaitingReply?.(sid, false) } catch { /* ignore */ }
1941
2036
  return {
1942
2037
  toast: '已发送 Esc',
1943
2038
  chosenLabel: '拒绝/退出(Esc)',