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
@@ -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 }