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.
@@ -24,34 +24,62 @@ const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
24
24
  const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
25
25
  const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
26
26
  const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
27
+ // Unicode Block Elements (U+2580-259F):▀▁▂▃▄▅▆▇█ ▉▊▋▌▍▎▏ ▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟
28
+ // Cursor TUI 用这些字符画状态栏 / 进度条 / 边框,连一串看起来就是大片黑条。
29
+ const BOX_BLOCK = /[▀-▟]/g
27
30
 
28
31
  // Claude TUI 噪声 —— 与 openclaw-hook.js 保持同步
29
32
  const SPINNER_CHARS_STR = '✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈'
30
33
  // "Brewing for 3m" / "Skedaddled for 5s" / "Cooked." 这类 spinner 状态行
31
34
  const STATUS_KEYWORDS = /\b[A-Z][a-z]{2,19}(?:ing|ed)\s+for\s+/
32
35
  const STATUS_VERB_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]*\s*[A-Z][a-z]{2,19}(?:ing|ed)\s*(…|\.\.\.|\.\.|\.)\s*$/
36
+ // 真实形态:"✽ Embellishing… 7 303 thinking more"——spinner + 动词 + 后面一堆杂物
37
+ // 老的 STATUS_VERB_LINE 要求整行只有 spinner+verb,匹配不上。这条更宽松:spinner 起头 + 动词,
38
+ // 后面爱写啥写啥都丢掉。
39
+ const SPINNER_PROGRESS_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]\s+[A-Z][a-z]{2,19}(?:ing|ed)\b/
33
40
  // 行首单独的指示符行(不带任何内容)
34
41
  const TUI_PROMPT_LINE = /^\s*[❯⏵►→]\s*$/
35
42
  const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
36
43
  const BORDER_ONLY = /^[\s\-=_|+~]+$/
37
44
 
45
+ // Claude 真权限框/选择器底部固定 footer(cleanPtyTail 不会过滤掉这行)。
46
+ // 跟 claude-prompt-detector 里的 CLAUDE_PERMISSION_FOOTER 是同一份语义。
47
+ const CLAUDE_FOOTER_RE = /Esc\s+to\s+cancel|Tab\s+to\s+amend|Tab\s+to\s+select/i
48
+
38
49
  // 已知的"该停下来等用户"锚点。命中后我们围绕它取窗口,避免把锚点前的 prompt
39
- // 文本(Bash 命令、文件路径、warning 等)切掉。多语言都列上,省得后续再扩。
50
+ // 文本(Bash 命令、文件路径、warning 等)切掉。
51
+ //
52
+ // 通用化(用户回归:edit pty.js 那条没命中,因为老 whitelist 漏了一些措辞):
53
+ // Claude 的标准提问全部是 "Do you want to <verb> ...?" 句型——proceed / make this
54
+ // edit / make this change / create / write / install / run / ... whitelist 永远追不
55
+ // 上 Claude 的新词。直接放宽成 `/Do you want to/i` 这一条通用 pattern,配合
56
+ // footer-at-bottom + ≥2 数字选项的强守卫已经够区分 AI 自由回复(AI 回复不会
57
+ // 末尾带字面 "Esc to cancel · Tab to amend")。
58
+ //
59
+ // 老的 Codex 单行 `[y/N]` / `apply patch?` 也留着;不影响 Claude 路径。
40
60
  const PERMISSION_ANCHORS = [
41
- /Do you want to proceed/i,
42
- /Do you want to make this edit/i,
43
- /Do you want to make this change/i,
44
- /Do you want to create/i,
61
+ /Do you want to\b/i, // 通用 Claude 提问
45
62
  /Allow this/i,
46
- /apply patch\?/i,
47
- /run this command\?/i,
63
+ /apply patch\?/i, // legacy codex
64
+ /run this command\?/i, // legacy codex
48
65
  /Approve\??/i,
49
- /\?\s*\[[yYnN]\/[yYnN]\]/,
66
+ /\?\s*\[[yYnN]\/[yYnN]\]/, // legacy codex y/N
50
67
  /(允许|批准|授权).*\?/,
51
68
  ]
52
69
 
53
- function stripAnsi(s) {
70
+ // Claude Code (ink/yoga) TUI 用 CUF(cursor forward, `\x1b[NC`)和 CUD(cursor down,
71
+ // `\x1b[NB`)做空白对齐,而不是直接打空格/换行。如果先无脑 strip 掉 CSI,对齐空白
72
+ // 就跟着没了——"Do you want to proceed" 会变成 "Doyouwanttoproceed",PERMISSION_ANCHORS
73
+ // 这种带字面量空格的 regex 全部失配,detector 永远 emit 不出来。
74
+ // 修复:strip CSI 之前先把 CUF/CUD 还原成对应数量的空格/换行;缺省参数 N 视作 1。
75
+ function expandCursorMoves(s) {
54
76
  return String(s || '')
77
+ .replace(/\x1b\[(\d*)C/g, (_m, n) => ' '.repeat(Math.min(parseInt(n, 10) || 1, 200)))
78
+ .replace(/\x1b\[(\d*)B/g, (_m, n) => '\n'.repeat(Math.min(parseInt(n, 10) || 1, 50)))
79
+ }
80
+
81
+ function stripAnsi(s) {
82
+ return expandCursorMoves(s)
55
83
  .replace(ANSI_OSC, '')
56
84
  .replace(ANSI_CSI, '')
57
85
  .replace(ANSI_OTHER, '')
@@ -64,6 +92,7 @@ function stripBoxDrawing(s) {
64
92
  .replace(BOX_VERTICAL, '')
65
93
  .replace(BOX_CORNERS, '')
66
94
  .replace(BOX_TEES, '')
95
+ .replace(BOX_BLOCK, '')
67
96
  }
68
97
 
69
98
  function compactBlankLines(s) {
@@ -81,6 +110,7 @@ function isSpinnerOnly(line) {
81
110
 
82
111
  function isNoiseLine(line) {
83
112
  if (STATUS_VERB_LINE.test(line)) return true
113
+ if (SPINNER_PROGRESS_LINE.test(line)) return true
84
114
  if (STATUS_KEYWORDS.test(line)) return true
85
115
  if (TUI_PROMPT_LINE.test(line)) return true
86
116
  if (AUTO_MODE_LINE.test(line)) return true
@@ -137,19 +167,71 @@ function findAnchorIndex(lines) {
137
167
  }
138
168
 
139
169
  /**
140
- * 从清洗后的 lines 取一个"覆盖授权 prompt 的窗口"
141
- * - 找到锚点:起点 = anchor - maxLines*0.7,终点 = anchor + maxLines*0.3 + 1
142
- * (要把选项行也带进来)
143
- * - 没找到锚点:直接取尾部 maxLines
170
+ * 严格的"真权限框"窗口定位:
171
+ *
172
+ * 1. footer (Esc to cancel · Tab to amend) 必须在最后 5 行 —— 屏幕**当前**正在显示
173
+ * 权限框,不是缓冲深处某次老 prompt 的残骸;
174
+ * 2. footer 上面 maxBack 行内必须找到 anchor (Do you want to ...);
175
+ * 3. anchor 和 footer 之间必须有 ≥2 个数字选项 (1. Yes / 2. No)。
176
+ *
177
+ * 三个信号全在一个紧凑、顺序正确的窗口里才认。这条规则把
178
+ * "AI 自由回复里恰好出现 anchor、缓冲老地方有 footer、又有 markdown 数字列表"
179
+ * 这种零散信号拼出来的假阳性挡掉。
180
+ *
181
+ * 命中:返回 { startIdx, footerIdx, options };不命中:null。
144
182
  */
145
- function takeWindow(lines, maxLines) {
146
- const idx = findAnchorIndex(lines)
147
- if (idx >= 0) {
148
- const back = Math.floor(maxLines * 0.7)
149
- const fwd = Math.ceil(maxLines * 0.3)
150
- return lines.slice(Math.max(0, idx - back), Math.min(lines.length, idx + fwd + 1))
183
+ function findStrictPermissionWindow(lines, { maxBack = 15, contextLinesBeforeAnchor = 8 } = {}) {
184
+ // 1) 找缓冲里"最后"一次出现的 footer(不再硬限末 5 行 —— Claude TUI 把 TodoWrite
185
+ // 状态面板、active 任务列表等渲染在 prompt 之后,footer 会被它们顶到中间)。
186
+ // "最后一次"语义:如果缓冲里有多个老 prompt,用最新的那个的 footer。
187
+ // 紧凑性 (anchor footer 上方 15 行内) + 数字选项的约束已经够区分 AI 自由
188
+ // 回复(自由回复不会刚好"anchor + ≥2 数字选项 + Esc to cancel"全部出现在
189
+ // 15 行紧凑窗口里)。
190
+ let footerIdx = -1
191
+ for (let i = lines.length - 1; i >= 0; i--) {
192
+ if (CLAUDE_FOOTER_RE.test(lines[i])) { footerIdx = i; break }
193
+ }
194
+ if (footerIdx < 0) return null
195
+
196
+ // 2) footer 上方 maxBack 行内找 anchor
197
+ const searchFloor = Math.max(0, footerIdx - maxBack)
198
+ let anchorIdx = -1
199
+ for (let i = footerIdx - 1; i >= searchFloor; i--) {
200
+ if (PERMISSION_ANCHORS.some((re) => re.test(lines[i]))) { anchorIdx = i; break }
151
201
  }
152
- return lines.slice(-maxLines)
202
+ if (anchorIdx < 0) return null
203
+
204
+ // 3) anchor → footer 之间 ≥2 数字选项
205
+ const options = []
206
+ const seen = new Map()
207
+ for (let i = anchorIdx + 1; i < footerIdx; i++) {
208
+ const m = lines[i].match(/^\s*([1-9])\.\s+(\S.{0,79}?)\s*$/)
209
+ if (!m) continue
210
+ const idx = parseInt(m[1], 10)
211
+ const label = m[2].trim()
212
+ if (!label || seen.has(idx)) continue
213
+ seen.set(idx, label)
214
+ options.push({ index: idx, label })
215
+ }
216
+ if (options.length < 2) return null
217
+
218
+ // 4) 起点往 anchor 上方再退一段(典型形态:Bash command / Edit file path / description
219
+ // 等几行在 anchor 上方)。stop 在空行或第二个连续空行,避免把上一帧 chat 内容卷进来。
220
+ let startIdx = anchorIdx
221
+ let blanksSeen = 0
222
+ for (let i = anchorIdx - 1; i >= Math.max(0, anchorIdx - contextLinesBeforeAnchor); i--) {
223
+ const isBlank = !lines[i].trim()
224
+ if (isBlank) {
225
+ blanksSeen++
226
+ if (blanksSeen >= 2) break // 两个空行 = 工具盒上面,截断
227
+ startIdx = i
228
+ continue
229
+ }
230
+ blanksSeen = 0
231
+ startIdx = i
232
+ }
233
+
234
+ return { startIdx, footerIdx, options }
153
235
  }
154
236
 
155
237
  /**
@@ -210,23 +292,23 @@ export function extractPermissionPrompt(
210
292
  ) {
211
293
  function extract(source) {
212
294
  const cleaned = cleanPtyTail(source)
213
- if (!cleaned) return ''
295
+ if (!cleaned) return { text: '', options: [] }
214
296
  const lines = cleaned.split('\n')
215
- const window = takeWindow(lines, maxLines)
216
- let text = window.join('\n').trim()
297
+ const m = findStrictPermissionWindow(lines, { maxBack: maxLines, footerTailRange: 5 })
298
+ if (!m) return { text: '', options: [] }
299
+ let text = lines.slice(m.startIdx, m.footerIdx + 1).join('\n').trim()
217
300
  if (text.length > maxChars) text = text.slice(-maxChars)
218
- return text
301
+ return { text, options: m.options }
219
302
  }
220
303
 
221
- let text = extract(raw)
222
- // 主源里没有锚点或太瘦回退完整历史尾部
223
- const hasAnchor = (s) => PERMISSION_ANCHORS.some((re) => re.test(s))
224
- if ((!text || text.length < 40 || !hasAnchor(text)) && historicalRaw) {
304
+ let { text, options } = extract(raw)
305
+ // 主源里 strict window 没命中 回退完整历史尾部再试一次
306
+ if ((!text || options.length < 2) && historicalRaw) {
225
307
  const fallback = extract(historicalRaw)
226
- if (fallback && (hasAnchor(fallback) || fallback.length > text.length)) {
227
- text = fallback
308
+ if (fallback.text && fallback.options.length >= 2) {
309
+ text = fallback.text
310
+ options = fallback.options
228
311
  }
229
312
  }
230
- const options = parsePermissionOptions(text)
231
313
  return { text, options }
232
314
  }