agentquad 0.4.1 → 0.4.3

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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Markdown → 飞书 post 富文本 AST。
3
+ * 主线场景:LLM / plan 完成通知这类含表格 + 多级标题的长 markdown,发飞书时
4
+ * 不再降级成纯文本被 toLarkText 摊平,改成 post 富文本,让飞书原生渲染粗体、
5
+ * 链接、代码块。
6
+ *
7
+ * post 的能力边界:
8
+ * - text (style: bold/italic/underline/lineThrough), a, at, img, code_block, hr
9
+ * - 没有 heading 多级、没有 table、没有 list 原语,全部要自己用前缀/样式模拟
10
+ *
11
+ * 输出形状(飞书 im.message.create 直传):
12
+ * { zh_cn: { title?: '', content: [[ {tag,...}, ... ], ...] } }
13
+ * content 是"段落数组",每段是 tag 数组,段间飞书自动换行。
14
+ */
15
+
16
+ // 块级 markdown 判据:只有命中"块级"特征才升级到 post,避免普通正文里偶尔
17
+ // 出现的 **bold** / *italic* 误触发。
18
+ const BLOCK_PATTERNS = [
19
+ /^#{1,6}\s/m, // 行首 1-6 个 # + 空格
20
+ /^\|.*\|\s*$\n\s*\|\s*[-:|\s]+\|/m, // 表格:行首 | + 紧跟分隔行
21
+ /^```/m, // 围栏代码块
22
+ /^[-*]\s/m, // 列表 - / *
23
+ /^\d+\.\s/m, // 列表 1.
24
+ /^>\s/m, // 引用
25
+ ]
26
+
27
+ export function isMarkdownLike(text) {
28
+ if (typeof text !== 'string' || !text) return false
29
+ return BLOCK_PATTERNS.some((re) => re.test(text))
30
+ }
31
+
32
+ const HEADER_PREFIX = ['━━━ ', '▎', '· ', '· ', '· ', '· ']
33
+
34
+ /**
35
+ * 把行内 markdown(**bold**, *italic*, [text](url), `code`)切成 post tag 数组。
36
+ * 不处理块级元素。
37
+ */
38
+ function inlineTokens(line) {
39
+ const tokens = []
40
+ let i = 0
41
+ const len = line.length
42
+
43
+ function pushText(text, style) {
44
+ if (!text) return
45
+ const node = { tag: 'text', text }
46
+ if (style && style.length) node.style = style
47
+ tokens.push(node)
48
+ }
49
+
50
+ let buf = ''
51
+ while (i < len) {
52
+ // 链接 [label](url)
53
+ const linkMatch = line.slice(i).match(/^\[([^\]]+)\]\(([^)]+)\)/)
54
+ if (linkMatch) {
55
+ pushText(buf); buf = ''
56
+ tokens.push({ tag: 'a', text: linkMatch[1], href: linkMatch[2] })
57
+ i += linkMatch[0].length
58
+ continue
59
+ }
60
+ // 粗体 **x**
61
+ if (line[i] === '*' && line[i + 1] === '*') {
62
+ const end = line.indexOf('**', i + 2)
63
+ if (end > i + 2) {
64
+ pushText(buf); buf = ''
65
+ pushText(line.slice(i + 2, end), ['bold'])
66
+ i = end + 2
67
+ continue
68
+ }
69
+ }
70
+ // 粗体 __x__
71
+ if (line[i] === '_' && line[i + 1] === '_') {
72
+ const end = line.indexOf('__', i + 2)
73
+ if (end > i + 2) {
74
+ pushText(buf); buf = ''
75
+ pushText(line.slice(i + 2, end), ['bold'])
76
+ i = end + 2
77
+ continue
78
+ }
79
+ }
80
+ // 斜体 *x*(前后非字母/星号/反斜杠)
81
+ if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*' && line[i + 1] !== ' ' && line[i - 1] !== '\\') {
82
+ const rest = line.slice(i + 1)
83
+ const m = rest.match(/^([^*\n]+?)\*(?!\w)/)
84
+ if (m) {
85
+ pushText(buf); buf = ''
86
+ pushText(m[1], ['italic'])
87
+ i += 1 + m[0].length
88
+ continue
89
+ }
90
+ }
91
+ // inline code `code` → post 无 inline code,用斜体近似(保留 backtick 视觉提示)
92
+ if (line[i] === '`') {
93
+ const end = line.indexOf('`', i + 1)
94
+ if (end > i + 1) {
95
+ pushText(buf); buf = ''
96
+ pushText(line.slice(i + 1, end), ['italic'])
97
+ i = end + 1
98
+ continue
99
+ }
100
+ }
101
+ buf += line[i]
102
+ i += 1
103
+ }
104
+ pushText(buf)
105
+ return tokens
106
+ }
107
+
108
+ function isTableSeparator(line) {
109
+ return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(line)
110
+ }
111
+
112
+ function splitTableRow(line) {
113
+ // 允许首尾 | 也允许没有;按未转义的 | 切
114
+ let s = line.trim()
115
+ if (s.startsWith('|')) s = s.slice(1)
116
+ if (s.endsWith('|')) s = s.slice(0, -1)
117
+ return s.split('|').map((c) => c.trim() || '—')
118
+ }
119
+
120
+ /**
121
+ * 把一段连续表格行转成 post 段落数组:
122
+ * - 表头行 → "**h1** | **h2** | ..."
123
+ * - 数据行 → "**c1** · c2 · c3 · ..."(首列 bold 当行锚点)
124
+ */
125
+ function renderTable(headerLine, dataLines) {
126
+ const paragraphs = []
127
+ const headers = splitTableRow(headerLine)
128
+ const headerTokens = []
129
+ headers.forEach((h, idx) => {
130
+ if (idx > 0) headerTokens.push({ tag: 'text', text: ' | ' })
131
+ headerTokens.push({ tag: 'text', text: h, style: ['bold'] })
132
+ })
133
+ paragraphs.push(headerTokens)
134
+
135
+ for (const row of dataLines) {
136
+ const cells = splitTableRow(row)
137
+ const rowTokens = []
138
+ cells.forEach((c, idx) => {
139
+ if (idx === 0) {
140
+ rowTokens.push({ tag: 'text', text: c, style: ['bold'] })
141
+ } else {
142
+ rowTokens.push({ tag: 'text', text: ' · ' })
143
+ // 单元格内的行内 markdown 也展开
144
+ const inline = inlineTokens(c)
145
+ for (const t of inline) rowTokens.push(t)
146
+ }
147
+ })
148
+ paragraphs.push(rowTokens)
149
+ }
150
+ return paragraphs
151
+ }
152
+
153
+ /**
154
+ * 主转换:markdown 字符串 → post AST { zh_cn: { content: [[...]] } }
155
+ */
156
+ export function toLarkPost(markdown) {
157
+ const text = typeof markdown === 'string' ? markdown : String(markdown ?? '')
158
+ const lines = text.split('\n')
159
+ const content = []
160
+ let i = 0
161
+
162
+ while (i < lines.length) {
163
+ const line = lines[i]
164
+ const trimmed = line.trimEnd()
165
+
166
+ // 围栏代码块
167
+ const fenceMatch = trimmed.match(/^```([a-zA-Z0-9_+-]*)\s*$/)
168
+ if (fenceMatch) {
169
+ const language = fenceMatch[1] || ''
170
+ const codeLines = []
171
+ i += 1
172
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
173
+ codeLines.push(lines[i])
174
+ i += 1
175
+ }
176
+ i += 1 // 跳过闭合 ```
177
+ const codeText = codeLines.join('\n')
178
+ const node = { tag: 'code_block', text: codeText }
179
+ if (language) node.language = language
180
+ content.push([node])
181
+ continue
182
+ }
183
+
184
+ // 水平线
185
+ if (/^\s*([-*_])\1{2,}\s*$/.test(trimmed)) {
186
+ content.push([{ tag: 'hr' }])
187
+ i += 1
188
+ continue
189
+ }
190
+
191
+ // 标题
192
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/)
193
+ if (headingMatch) {
194
+ const level = headingMatch[1].length
195
+ const body = headingMatch[2].trim()
196
+ const prefix = HEADER_PREFIX[level - 1] || '· '
197
+ const suffix = level === 1 ? ' ━━━' : ''
198
+ content.push([{ tag: 'text', text: `${prefix}${body}${suffix}`, style: ['bold'] }])
199
+ i += 1
200
+ continue
201
+ }
202
+
203
+ // 表格:当前行像 | a | b | 且下一行是分隔行
204
+ if (/^\s*\|.*\|\s*$/.test(trimmed) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
205
+ const headerLine = trimmed
206
+ const dataLines = []
207
+ i += 2 // 跳过 header + 分隔
208
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
209
+ dataLines.push(lines[i].trimEnd())
210
+ i += 1
211
+ }
212
+ const paragraphs = renderTable(headerLine, dataLines)
213
+ for (const p of paragraphs) content.push(p)
214
+ continue
215
+ }
216
+
217
+ // 引用 > x
218
+ const quoteMatch = trimmed.match(/^>\s?(.*)$/)
219
+ if (quoteMatch) {
220
+ const inner = quoteMatch[1]
221
+ content.push([
222
+ { tag: 'text', text: '▎ ' },
223
+ ...inlineTokens(inner),
224
+ ])
225
+ i += 1
226
+ continue
227
+ }
228
+
229
+ // 列表项
230
+ const ulMatch = line.match(/^(\s*)([-*])\s+(.*)$/)
231
+ if (ulMatch) {
232
+ const indent = ulMatch[1]
233
+ const body = ulMatch[3]
234
+ content.push([
235
+ { tag: 'text', text: `${indent}• ` },
236
+ ...inlineTokens(body),
237
+ ])
238
+ i += 1
239
+ continue
240
+ }
241
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/)
242
+ if (olMatch) {
243
+ const indent = olMatch[1]
244
+ const num = olMatch[2]
245
+ const body = olMatch[3]
246
+ content.push([
247
+ { tag: 'text', text: `${indent}${num}. ` },
248
+ ...inlineTokens(body),
249
+ ])
250
+ i += 1
251
+ continue
252
+ }
253
+
254
+ // 空行:插入一个空段落让飞书产生段间距
255
+ if (trimmed === '') {
256
+ content.push([{ tag: 'text', text: '' }])
257
+ i += 1
258
+ continue
259
+ }
260
+
261
+ // 图片:丢弃
262
+ if (/^\s*!\[[^\]]*\]\([^)]+\)\s*$/.test(trimmed)) {
263
+ i += 1
264
+ continue
265
+ }
266
+
267
+ // 普通段落
268
+ // 处理行内图片:删除
269
+ const cleaned = line.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
270
+ content.push(inlineTokens(cleaned))
271
+ i += 1
272
+ }
273
+
274
+ // 修剪末尾的空段落
275
+ while (content.length > 0) {
276
+ const last = content[content.length - 1]
277
+ if (last.length === 1 && last[0].tag === 'text' && last[0].text === '') {
278
+ content.pop()
279
+ } else {
280
+ break
281
+ }
282
+ }
283
+
284
+ return { zh_cn: { content } }
285
+ }
package/src/mcp/server.js CHANGED
@@ -13,8 +13,10 @@ const SERVER_NAME = 'agentquad'
13
13
  /**
14
14
  * 创建一个挂在 Express 下的 MCP Streamable HTTP 路由。
15
15
  *
16
- * 工作方式:一个全局 McpServer + 一个全局 StreamableHTTPServerTransport,stateless 模式。
17
- * 每个 HTTP 请求都由 transport.handleRequest 完整处理。
16
+ * 关键:MCP SDK stateless 模式(sessionIdGenerator: undefined)规定每个 HTTP 请求
17
+ * 必须用新的 transport —— 共享会抛 "Stateless transport cannot be reused"。所以我们
18
+ * 在每次请求时新建 transport + server + tool 注册,audit / scanner 这种重对象在 router
19
+ * 工厂里建一次就够,复用给每个 per-request server。
18
20
  *
19
21
  * 依赖:
20
22
  * - db:openDb(...) 返回的句柄
@@ -33,34 +35,45 @@ export function createMcpRouter({
33
35
  if (!db) throw new Error('db_required')
34
36
  if (!searchService) throw new Error('searchService_required')
35
37
 
36
- const server = new McpServer({
37
- name: SERVER_NAME,
38
- version: (typeof getVersion === 'function' && getVersion()) || '0.1.0',
39
- })
40
-
38
+ // 重对象只建一次,复用给 per-request server
41
39
  const audit = rootDir ? createAuditLog({ rootDir }) : null
42
40
  const transcriptScanner = logDir ? createTranscriptScanner({ db, logDir }) : null
41
+ const serverVersion = (typeof getVersion === 'function' && getVersion()) || '0.1.0'
43
42
 
44
- registerReadTools(server, { db, searchService, wikiDir, transcriptScanner })
45
- registerWriteTools(server, { db })
46
- registerDestructiveTools(server, { db, audit })
47
- if (pending) {
48
- registerOpenClawTools(server, { db, aiTerminal, openclaw, pending, getConfig })
43
+ function buildServer() {
44
+ const server = new McpServer({ name: SERVER_NAME, version: serverVersion })
45
+ registerReadTools(server, { db, searchService, wikiDir, transcriptScanner })
46
+ registerWriteTools(server, { db })
47
+ registerDestructiveTools(server, { db, audit })
48
+ if (pending) {
49
+ registerOpenClawTools(server, { db, aiTerminal, openclaw, pending, getConfig })
50
+ }
51
+ return server
49
52
  }
50
53
 
51
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
52
- // 异步 connect;路由处理器会等这个 promise resolve 之后再调 handleRequest。
53
- const ready = server.connect(transport)
54
-
55
54
  const router = express.Router()
56
55
  // MCP Streamable HTTP 约定:客户端用 POST /mcp 下发 JSON-RPC;
57
- // 对于 SSE 变体或重连,GET 会触发会话初始化。
58
- // 我们是 stateless mode,所以两种方法都交给 transport.handleRequest
56
+ // SSE 重连或 server-sent 流走 GET。stateless 模式下两种方法都走同一段:
57
+ // 每请求一个全新 transport + server
59
58
  const handle = async (req, res) => {
59
+ let transport
60
+ let server
60
61
  try {
61
- await ready
62
+ transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
63
+ server = buildServer()
64
+ await server.connect(transport)
65
+
66
+ // 请求结束/客户端断开时清理;防止泄漏
67
+ res.on('close', () => {
68
+ try { transport.close?.() } catch { /* ignore */ }
69
+ try { server.close?.() } catch { /* ignore */ }
70
+ })
71
+
62
72
  await transport.handleRequest(req, res, req.body)
63
73
  } catch (e) {
74
+ console.error('[mcp] handleRequest threw:', e?.stack || e?.message || e)
75
+ try { transport?.close?.() } catch { /* ignore */ }
76
+ try { server?.close?.() } catch { /* ignore */ }
64
77
  if (!res.headersSent) {
65
78
  res.status(500).json({
66
79
  jsonrpc: '2.0',
@@ -76,8 +89,8 @@ export function createMcpRouter({
76
89
 
77
90
  // 健康检查(MCP 客户端一般不走这个,但方便 `agentquad mcp status` 和运维)
78
91
  router.get('/health', (_req, res) => {
79
- res.json({ ok: true, server: SERVER_NAME, tools: server._registeredTools ? Object.keys(server._registeredTools).length : undefined })
92
+ res.json({ ok: true, server: SERVER_NAME, version: serverVersion })
80
93
  })
81
94
 
82
- return { router, server, transport }
95
+ return { router }
83
96
  }
@@ -164,6 +164,11 @@ export function registerOpenClawTools(server, deps) {
164
164
  routeUserId: z.string().optional().describe('OpenClaw 微信对端 user_id,用于回推'),
165
165
  routeAccount: z.string().optional(),
166
166
  routeChannel: z.string().optional(),
167
+ parentTodoId: z.string().optional().describe(
168
+ '调用方(父 PTY 的 AI)从 env QUADTODO_TODO_ID 读取后透传过来,' +
169
+ '用于子 PTY 注入 QUADTODO_PARENT_TODO_ID。' +
170
+ 'AgentQuad 主进程的 process.env 不含父 todo 信息,所以必须显式传。'
171
+ ),
167
172
  },
168
173
  },
169
174
  async (args) => {
@@ -223,6 +228,7 @@ export function registerOpenClawTools(server, deps) {
223
228
  const result = aiTerminal.spawnSession({
224
229
  sessionId,
225
230
  todoId: args.todoId,
231
+ parentTodoId: args.parentTodoId || null, // ← new
226
232
  prompt,
227
233
  tool,
228
234
  cwd,
@@ -605,7 +605,7 @@ export function createOpenClawHookHandler(deps = {}) {
605
605
  if (!sess) return { ok: false, reason: 'session_gone' }
606
606
  // 把 session.status 翻成 pending_confirm —— 前端 deriveAiState 据此显示"待确认"。
607
607
  // 信号源是 codex-prompt-detector(已经过 AI self-quoted 过滤),比旧的 PTY 正则路径准。
608
- try { aiTerminal?.markPendingConfirm?.(sessionId, { source: 'codex-detector' }) } catch { /* ignore */ }
608
+ try { aiTerminal?.markPendingConfirm?.(sessionId, { source: 'codex-detector', promptText }) } catch { /* ignore */ }
609
609
  const todoId = sess.todoId
610
610
  let todoTitle = todoId
611
611
  try {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * 从 PTY 尾部抽出 Claude Code / Codex 的"授权弹窗"文本与候选选项。
3
+ *
4
+ * 双源策略:
5
+ * - 主源 raw:session.recentOutput(4KB 滑窗,新鲜但被 TUI redraw 噪声覆盖)
6
+ * 或 codex-prompt-detector 已经 ANSI-strip 过的短串。
7
+ * - 兜底 historicalRaw:session.outputHistory join 后的尾部(更大,旧但完整)。
8
+ *
9
+ * 输出 { text, options }:
10
+ * - text: 清洗 + 噪声过滤 + 锚定窗口后的多行字符串,给前端 PermissionCard 渲染。
11
+ * - options: 形如 [{ index: 1, label: 'Yes' }, ...],按 index 升序。
12
+ *
13
+ * 设计:与 openclaw-hook.js 推 IM 时的清洗管线职责相似,但目标是"短而干净"——
14
+ * IM 那边整轮 transcript 都要,这里只要授权弹窗那几行。规则同源(spinner /
15
+ * status verb / prompt prefix / border 都过滤),避免两边漂移。
16
+ */
17
+
18
+ const ANSI_OSC = /\x1b\][^\x07]*(\x07|\x1b\\)/g
19
+ const ANSI_CSI = /\x1b\[[0-9;?]*[A-Za-z~]/g
20
+ const ANSI_OTHER = /\x1b[()#][A-Za-z0-9]|\x1b[>=<cDEHMNOPZ78]/g
21
+ const CTRL = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g
22
+
23
+ const BOX_HORIZONTAL = /[─━┄┅┈┉═]/g
24
+ const BOX_VERTICAL = /[│┃┆┇┊┋║]/g
25
+ const BOX_CORNERS = /[┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛╭╮╯╰╓╒╕╖╙╘╛╜╔╗╚╝]/g
26
+ const BOX_TEES = /[├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╠╣╦╩╬]/g
27
+
28
+ // Claude TUI 噪声 —— 与 openclaw-hook.js 保持同步
29
+ const SPINNER_CHARS_STR = '✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈'
30
+ // "Brewing for 3m" / "Skedaddled for 5s" / "Cooked." 这类 spinner 状态行
31
+ const STATUS_KEYWORDS = /\b[A-Z][a-z]{2,19}(?:ing|ed)\s+for\s+/
32
+ const STATUS_VERB_LINE = /^\s*[*✶✳✻✽★⚙∗⠁⠂⠄⡀⢀⠠⠐⠈]*\s*[A-Z][a-z]{2,19}(?:ing|ed)\s*(…|\.\.\.|\.\.|\.)\s*$/
33
+ // 行首单独的指示符行(不带任何内容)
34
+ const TUI_PROMPT_LINE = /^\s*[❯⏵►→]\s*$/
35
+ const AUTO_MODE_LINE = /(auto mode (on|off)|shift\+tab to cycle|ctrl\+[a-z]\b)/i
36
+ const BORDER_ONLY = /^[\s\-=_|+~]+$/
37
+
38
+ // 已知的"该停下来等用户"锚点。命中后我们围绕它取窗口,避免把锚点前的 prompt
39
+ // 文本(Bash 命令、文件路径、warning 等)切掉。多语言都列上,省得后续再扩。
40
+ 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,
45
+ /Allow this/i,
46
+ /apply patch\?/i,
47
+ /run this command\?/i,
48
+ /Approve\??/i,
49
+ /\?\s*\[[yYnN]\/[yYnN]\]/,
50
+ /(允许|批准|授权).*\?/,
51
+ ]
52
+
53
+ function stripAnsi(s) {
54
+ return String(s || '')
55
+ .replace(ANSI_OSC, '')
56
+ .replace(ANSI_CSI, '')
57
+ .replace(ANSI_OTHER, '')
58
+ .replace(CTRL, '')
59
+ }
60
+
61
+ function stripBoxDrawing(s) {
62
+ return String(s || '')
63
+ .replace(BOX_HORIZONTAL, '')
64
+ .replace(BOX_VERTICAL, '')
65
+ .replace(BOX_CORNERS, '')
66
+ .replace(BOX_TEES, '')
67
+ }
68
+
69
+ function compactBlankLines(s) {
70
+ return String(s || '').replace(/\n[ \t]*\n+/g, '\n\n')
71
+ }
72
+
73
+ function isSpinnerOnly(line) {
74
+ const trimmed = line.replace(/\s+/g, '')
75
+ if (!trimmed) return true
76
+ for (const ch of trimmed) {
77
+ if (!SPINNER_CHARS_STR.includes(ch) && !/\d/.test(ch)) return false
78
+ }
79
+ return true
80
+ }
81
+
82
+ function isNoiseLine(line) {
83
+ if (STATUS_VERB_LINE.test(line)) return true
84
+ if (STATUS_KEYWORDS.test(line)) return true
85
+ if (TUI_PROMPT_LINE.test(line)) return true
86
+ if (AUTO_MODE_LINE.test(line)) return true
87
+ if (BORDER_ONLY.test(line)) return true
88
+ return false
89
+ }
90
+
91
+ export function cleanPtyTail(raw) {
92
+ if (!raw) return ''
93
+ let s = stripAnsi(raw)
94
+ s = stripBoxDrawing(s)
95
+ // 去掉行尾空白
96
+ s = s.split('\n').map((l) => l.replace(/[ \t]+$/, '')).join('\n')
97
+ // 过滤纯噪声行;保留空行(后面 compactBlankLines 再合并)
98
+ s = s
99
+ .split('\n')
100
+ .filter((l) => {
101
+ if (!l.trim()) return true
102
+ if (isSpinnerOnly(l)) return false
103
+ if (isNoiseLine(l)) return false
104
+ return true
105
+ })
106
+ .join('\n')
107
+ // 去掉行首 ❯ / > 标记(保留内容,比如 "❯ 1. Yes" → "1. Yes")
108
+ s = s.split('\n').map((l) => l.replace(/^(\s*)(?:❯|>)\s+/, '$1')).join('\n')
109
+ return compactBlankLines(s).trim()
110
+ }
111
+
112
+ /**
113
+ * 在文本里找形如 "1. Yes" / "2. No, suggest changes" 的枚举选项。
114
+ * 找不到 → []。重复 index 仅保留首条。
115
+ */
116
+ export function parsePermissionOptions(cleaned) {
117
+ if (!cleaned) return []
118
+ const seen = new Map()
119
+ for (const l of cleaned.split('\n')) {
120
+ const m = l.match(/^\s*([1-9])\.\s+(\S.{0,79}?)\s*$/)
121
+ if (!m) continue
122
+ const idx = parseInt(m[1], 10)
123
+ const label = m[2].trim()
124
+ if (!label) continue
125
+ if (!seen.has(idx)) seen.set(idx, label)
126
+ }
127
+ return [...seen.entries()]
128
+ .sort((a, b) => a[0] - b[0])
129
+ .map(([index, label]) => ({ index, label }))
130
+ }
131
+
132
+ function findAnchorIndex(lines) {
133
+ for (let i = lines.length - 1; i >= 0; i--) {
134
+ if (PERMISSION_ANCHORS.some((re) => re.test(lines[i]))) return i
135
+ }
136
+ return -1
137
+ }
138
+
139
+ /**
140
+ * 从清洗后的 lines 取一个"覆盖授权 prompt 的窗口"。
141
+ * - 找到锚点:起点 = anchor - maxLines*0.7,终点 = anchor + maxLines*0.3 + 1
142
+ * (要把选项行也带进来)
143
+ * - 没找到锚点:直接取尾部 maxLines
144
+ */
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))
151
+ }
152
+ return lines.slice(-maxLines)
153
+ }
154
+
155
+ /**
156
+ * extractPermissionPrompt(raw, opts):
157
+ * - raw : 主 PTY tail / detector promptText
158
+ * - opts.historicalRaw : recentOutput 洗完仍过瘦时回退的更大原始串
159
+ * (建议传入 session.outputHistory.join('') 的尾部)
160
+ *
161
+ * 返回 { text, options };text 不超过 maxChars,options 默认 maxLines=30。
162
+ */
163
+ export function extractPermissionPrompt(
164
+ raw,
165
+ { historicalRaw = null, maxLines = 30, maxChars = 1200 } = {},
166
+ ) {
167
+ function extract(source) {
168
+ const cleaned = cleanPtyTail(source)
169
+ if (!cleaned) return ''
170
+ const lines = cleaned.split('\n')
171
+ const window = takeWindow(lines, maxLines)
172
+ let text = window.join('\n').trim()
173
+ if (text.length > maxChars) text = text.slice(-maxChars)
174
+ return text
175
+ }
176
+
177
+ let text = extract(raw)
178
+ // 主源里没有锚点或太瘦 → 回退完整历史尾部
179
+ const hasAnchor = (s) => PERMISSION_ANCHORS.some((re) => re.test(s))
180
+ if ((!text || text.length < 40 || !hasAnchor(text)) && historicalRaw) {
181
+ const fallback = extract(historicalRaw)
182
+ if (fallback && (hasAnchor(fallback) || fallback.length > text.length)) {
183
+ text = fallback
184
+ }
185
+ }
186
+ const options = parsePermissionOptions(text)
187
+ return { text, options }
188
+ }