@optima-chat/dev-skills 0.16.8 → 0.16.10

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.
@@ -11,8 +11,9 @@
11
11
 
12
12
  用法:
13
13
  from chat_driver import ChatDriver
14
- d = ChatDriver().attach() # 串行:复用页面上已有的 chat tab
15
- d = ChatDriver().attach(own_tab=True) # 并行:**自己开一个 tab**,独占一个 gateway session
14
+ d = ChatDriver().attach(ziniao="27884995544032") # **声明**这一轮驱动哪个 profile(#611;锁已撤,见下)
15
+ d = ChatDriver().attach(ziniao=None, reason="不碰任何店") # 显式声明;**理由必填**
16
+ d = ChatDriver().attach(own_tab=True, ziniao=None, reason="profile 在逐条用例那层声明") # 并行:自己开 tab
16
17
  d.ensure_installed(["briefing-store-status"])
17
18
  d.new_conversation()
18
19
  r = d.send_and_wait("跑一下老赵店的运营简报", timeout=300)
@@ -34,7 +35,10 @@
34
35
  """
35
36
  from __future__ import annotations
36
37
 
38
+ import json
37
39
  import os
40
+ import re
41
+ import subprocess
38
42
  import time
39
43
 
40
44
  from playwright.sync_api import sync_playwright
@@ -58,6 +62,491 @@ class TabSessionUnavailable(RuntimeError):
58
62
  绝不能降级成「那就共用一个 tab 吧」——那正是静默串台的成因。"""
59
63
 
60
64
 
65
+ # preflight 只问一件事、只认一种答案:让 agent 原样贴 `browser-cli ziniao doctor` 的输出。
66
+ # **别再让它用自然语言回答「连没连上」** —— 2026-09-12 两种翻车都出在解析自由文本上:
67
+ # · 假阴性:服务端回复完整(11 个 profile 都在),driver 侧解析到 0 个 → 整轮 0s blocked;
68
+ # · 假阳性:抓到 5 万字页面噪声,从里面正则捞出 4 个「27 开头的数字」就判连着,
69
+ # 而同一时刻 doctor 明写 `✗ 桌面应用: 鸭嘴兽助手桌面应用未连接`。
70
+ _PREFLIGHT_PROMPT = (
71
+ "只跑这两条命令,别加载任何 skill、别开浏览器、别做任何店铺操作,把**原始输出原样贴出来**"
72
+ "(不要解读、不要改写、不要转成表格):\n"
73
+ "1) `browser-cli ziniao doctor`\n"
74
+ "2) `browser-cli ziniao profiles`")
75
+
76
+
77
+
78
+ def parse_preflight(text: str) -> dict:
79
+ """从 preflight 回复里判桌面端连没连上。**纯函数,单测覆盖。**
80
+
81
+ 返回 {ok, stores, shop_count},其中 `ok` 是**三态**:
82
+ · True —— 看到 `✓ 桌面应用`(权威肯定)
83
+ · False —— 看到 `✗ 桌面应用`(权威否定)
84
+ · None —— 两个都没看到 = **没抓到**,不是「没连上」
85
+
86
+ 三态是关键:旧实现把「没抓到」和「没连上」压成同一个 False,于是一次抓取抖动就让整轮
87
+ e2e 一个用例都不驱动(2026-09-12 实测:服务端回复里 11 个 profile 齐全,driver 判「桌面未连」)。
88
+ 现在只有**权威否定**才拦;抓不到交给调用方决定(run.py 选择照跑并打警告)。
89
+
90
+ 店铺清单只从 `ziniao profiles` 的行首格式取(`<id> <名字>`),**不再全文捞 27 开头的数字**
91
+ ——页面噪声里遍地是这种数字,那正是假阳性的来源。
92
+ """
93
+ t = text or ""
94
+ ok = True if "✓ 桌面应用" in t else (False if "✗ 桌面应用" in t else None)
95
+ # ` 27884995544032 跨境-马来西亚-鸭嘴兽 [TikTok Shop-...]`:id 后至少两个空白再跟店名
96
+ stores = re.findall(r"(\d{14,15})\s{2,}([^\s\[][^\[\n]{1,38})", t)
97
+ m = re.search(r"店铺列表[::]\s*(\d+)\s*个", t)
98
+ return {"ok": ok, "stores": [(i, n.strip()) for i, n in stores],
99
+ "shop_count": int(m.group(1)) if m else None}
100
+
101
+
102
+ # ── 平台侧风控证据(#331)────────────────────────────────────────────────
103
+ # 判「撞到风控」**只能**拿平台/脚本自己产出的东西当证据。面板文本不行:它含着被回显的
104
+ # 用户 prompt,而按**停手纪律**写的 prompt 几乎一定带「遇到风控/验证码就停下」这句话
105
+ # ⇒ 越守纪律的用例越容易被判成假 blocked(#331 实证:同一条用例一天内误导两次,
106
+ # 真相是脚本级 hard failure `✗ Script failed: 变体名 Warna 未设上`,一次风控都没撞到)。
107
+ #
108
+ # 这里扫的是**工具结果体**(脚本 stdout),不是面板文本。
109
+ #
110
+ # 🔴 为什么必须挨着 `✗ Script failed` 才算:下面这些串**在 skill 源码里也是字面量**
111
+ # (`listing-product-on-tiktok/script.py` 的 `_CAPTCHA_PRESENT` 探针正则、
112
+ # `managing-affiliate-program/script.py` 的 `gate_err` 文案)。Agent 中途 `read` 一下
113
+ # 脚本源码,结果体里就带上了这些词 —— 那是**源码**不是**回执**。只认「脚本失败回执里
114
+ # 紧跟着说自己撞了风控」这一种形态,宁可漏也不误报(漏 → unknown → uncertain,仍要人核)。
115
+ #
116
+ # ⚠️ **本信号未经真机验证**:272 份历史日志里一次真风控停手都没记到(两次判成风控的都是假的)。
117
+ # 它在真机上到底会不会亮,要等下一次真撞到风控才知道。所以判定方**不许**把
118
+ # 「risk_hits==0」当成「没撞风控」—— 见 run.py `classify_risk_control` 的三态。
119
+ _RISK_RECEIPT_MARKS = (
120
+ "平台风控拼图验证", "风控铁律", "探到风控/验证码",
121
+ "请完成下列验证后继续", "請完成下列驗證後繼續", "拖动完成上方拼图", "拖動完成上方拼圖",
122
+ "完成安全验证", "滑动验证", "点选验证", "Slide to verify", "Verify to continue",
123
+ )
124
+ _RISK_RECEIPT_WINDOW = 300 # 拍的,无实测支撑:`✗ Script failed: <msg>` 的 msg 长度量级
125
+
126
+
127
+ def count_platform_risk_receipts(res_text: str) -> int:
128
+ """工具结果体里有几处**平台侧**风控停手回执(`✗ Script failed:` 后紧跟风控措辞)。
129
+
130
+ 只回整数,不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
131
+ """
132
+ t = res_text or ""
133
+ n = 0
134
+ for m in re.finditer(r"✗ Script failed", t):
135
+ seg = t[m.end():m.end() + _RISK_RECEIPT_WINDOW]
136
+ if any(k in seg for k in _RISK_RECEIPT_MARKS):
137
+ n += 1
138
+ return n
139
+
140
+
141
+
142
+ # ── 平台侧「每账号一个活动会话」回执(#449 第四档)──────────────────────
143
+ # 那条绊线(`session_limit_regressed`)原来扫**整轮面板文本**,而面板里含被回显的 prompt、
144
+ # agent 的否定句和推测。它已经做了 strip_echo(#390,挡回显),**但挡不住否定句和推测**。
145
+ #
146
+ # 🔴 这一档比风控那档更该严 —— 代价不对称:
147
+ # · 风控误报 = 白叫店主看一眼;
148
+ # · **这一档误报 = 让人把已经拆掉的按店互斥改回去**,
149
+ # 等于拿一次误判去撤销一个有 wire 实证的修复(`optima-browser-use#345` / #383)。
150
+ #
151
+ # ⚠️ **而这两个标记本身没有任何真实样本**:2026-09-13 自己数过 **277 份** e2e 日志,
152
+ # 两个标记**各 0 命中**(标记是照旧文档字符串抄的)。⇒ 即使只认工具结果体,
153
+ # 它**仍然是在等一个没人见过的字符串**。判定方必须把这句写进判词,
154
+ # 别让下一个人把「没响」读成「没回归」。
155
+ _SESSION_LIMIT_MARKS = (
156
+ "already has an active session", # 英文(含带 "User " 前缀的服务端原文)
157
+ "已有一个活动会话", # 中文回执
158
+ )
159
+
160
+ # ── #671:上面那条「只认工具结果体」的收窄**不够**,它误吃了两轮 ──────────────
161
+ #
162
+ # 🔴 **隔壁 30 行处的风控计数早就写清了为什么必须挨着失败回执**(见 `count_platform_risk_receipts`
163
+ # 上方注释):「这些串**在 skill 源码里也是字面量** …… Agent 中途 `read` 一下脚本源码,
164
+ # 结果体里就带上了这些词 —— 那是**源码**不是**回执**。」
165
+ # **而这一档当时只写了 `sum(t.count(m))`,那道收窄一个字都没抄过来。**
166
+ #
167
+ # 本仓自己就是那个反例(2026-09-13 数):
168
+ # `tiktok/skills/publishing-shoppable-video/SKILL.md` **3 处**
169
+ # `tiktok/skills/exporting-ad-reports/script.py` **1 处**
170
+ # —— 都是**文档在引用这句报错**,没有一处是回执。
171
+ #
172
+ # ⇒ 两轮误判的现场(#671,`e2e/logs/2026-09-13-1709-*` 与 `-1732-*`):
173
+ #
174
+ # | | 第一轮 | 第二轮 |
175
+ # |---|---|---|
176
+ # | 真实原因 | 登录墙 / 账号停用(#670) | 风控滑块拼图 |
177
+ # | 判成 | 单会话约束回归 | 单会话约束回归 |
178
+ # | `session_limit_hits` | **2** | **2** |
179
+ # | **同一份日志里的工具轨迹** | **34 个工具,失败 0** | **21 个工具,失败 0** |
180
+ #
181
+ # 🔴 **一个「平台侧回执」,出现在一轮里每个工具都成功的会话中。**
182
+ # 平台因为会话上限**拒绝**了你,这件事不可能不表现为某个调用失败。
183
+ # **这个矛盾就印在判词下面两行**,两轮都是,没人看。
184
+ # 而两轮的计数**同为 2** —— 恒定值,最像文档里的固定出现次数,最不像随机发生的故障。
185
+ #
186
+ # ⇒ 收窄成和风控同一种形状:**只认挨着失败回执的那一种**。
187
+ #
188
+ # ⚠️ **锚点本身未经真机验证**(这两个标记在 277 份日志 / 1485 份 wire 里从没以回执形态出现过,
189
+ # 唯一一次是一句否定句)。所以**收窄之后不许直接落到「没有」**:
190
+ # 数到了、但没挨着任何失败回执 → 判定方必须回 `unknown`,让人去 Wire 取原文。
191
+ # **宁可让它一直停在 unknown,也不许它再自信地指向一个不存在的方向。**
192
+ #
193
+ # 🔴 **锚点必须是机器吐出来的那种串,不能是普通词。**
194
+ # 第一版我把 `失败` / `报错` / `错误` / 裸 `Error` 也列成锚点 —— **当场被真实文档打红**:
195
+ # `publishing-shoppable-video/SKILL.md` 那段写的是「门控**失败**时浏览器会话故意留着…
196
+ # 紧接着重跑可能撞 `User already has an active session`」
197
+ # ⇒ **散文里的「失败」离标记只有几十个字**,锚点照样点着。
198
+ # **这就是拿一份已知为真的样本先校准判据的价值**(L11):它在我写下的那一刻就是错的。
199
+ _SESSION_LIMIT_ANCHORS = (
200
+ "✗ Script failed", # 脚本任务级失败回执(机器吐的)
201
+ "Error:", "error:", "ERROR:", # 带冒号 —— 命令/gateway 的错误串,散文里不会这么写
202
+ "Traceback (most recent call last)",
203
+ )
204
+ _SESSION_LIMIT_WINDOW = 300 # 同 `_RISK_RECEIPT_WINDOW`:拍的,无实测支撑
205
+
206
+
207
+ def count_session_limit_mentions(res_text: str) -> int:
208
+ """结果体里这两个标记**总共**出现几处 —— 含文档、源码、复述、否定句。
209
+
210
+ 🔴 **这不是证据**,它是「有没有必要去 Wire 看一眼」的线索。
211
+ 判定方只许拿它走 `unknown`,**不许拿它判 `hit`**(那是 `count_session_limit_receipts`)。
212
+ """
213
+ t = res_text or ""
214
+ return sum(t.count(m) for m in _SESSION_LIMIT_MARKS)
215
+
216
+
217
+ def count_session_limit_receipts(res_text: str) -> int:
218
+ """工具结果体里有几处**挨着失败回执**的「每账号一个活动会话」标记。
219
+
220
+ 收窄形状与 `count_platform_risk_receipts` 一致:标记必须落在某个失败回执**之后**
221
+ `_SESSION_LIMIT_WINDOW` 个字符内,才算一次平台侧回执。**源码/文档里的字面量不算。**
222
+
223
+ 只回整数、不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
224
+ """
225
+ t = res_text or ""
226
+ n = 0
227
+ for mark in _SESSION_LIMIT_MARKS:
228
+ start = 0
229
+ while True:
230
+ i = t.find(mark, start)
231
+ if i < 0:
232
+ break
233
+ start = i + 1
234
+ seg = t[max(0, i - _SESSION_LIMIT_WINDOW):i]
235
+ if any(a in seg for a in _SESSION_LIMIT_ANCHORS):
236
+ n += 1
237
+ return n
238
+
239
+
240
+ # ── 平台侧登录态(#449)─────────────────────────────────────────────────
241
+ # 登录那一档的判据原来是**扫整轮面板文本里 agent 说的一句话**(「登录态过期」「尚未登录」…),
242
+ # 而 agent 那句话本身是它看一张截图推出来的,**零机读证据**。
243
+ # 2026-09-13 01:55 那轮的后果很具体:一个跳到本土域的杂散标签被报成「店掉线」,
244
+ # 判词写「非 skill 缺陷,登录后重跑」,**差点让店主白做一次登录 + 二步验证**。
245
+ #
246
+ # 平台侧的东西**已经有了**:`briefing-store-status` 的 `login_stop_output()` 吐结构化的
247
+ # `login_state` ∈ {logged_in, logged_out, unknown} + 带落地 URL 的 `login_why`。
248
+ # 而且那个字段自己就守着两条(#51):**落地 host 与预期不符 → unknown;读不到 URL → unknown**,
249
+ # 都**绝不**落到 `logged_out`。所以判定方只要认这个字段,就自动继承了那两条纪律。
250
+ #
251
+ # ⚠️ **目前只有 `briefing-store-status` 吐它**(#51 的 fast-fail 只做了 1/19,见 #435)。
252
+ # 所以绝大多数用例这里会是「没有平台侧证据」→ 判定方必须回 unknown,**不许回 blocked**。
253
+ _LOGIN_STATE_RE = re.compile(r"login_state[\"'\s:=]+([a-z_]+)")
254
+
255
+
256
+ def scrape_login_states(res_text: str) -> list:
257
+ """工具结果体里**脚本自报**的 login_state 取值(按出现顺序)。
258
+
259
+ 只回取值列表,不回原文 —— 结果体可能含 env 明文密钥/JWT(gw#2350),同 `_scrape_tool_trace`。
260
+ 取值不在白名单里的一概丢掉:宁可漏(→ unknown → uncertain,仍要人核),不误报。
261
+ """
262
+ ok = ("logged_in", "logged_out", "unknown")
263
+ return [m.group(1) for m in _LOGIN_STATE_RE.finditer(res_text or "") if m.group(1) in ok]
264
+
265
+
266
+ # ── 紫鸟 profile 的**声明**闸(#611;锁那一半已撤)────────────────────
267
+ #
268
+ # 🔴 **`ziniao-<profile_id>` 这把锁撤销了**(`working-as-worker` 的锁那一节 / 禁令 `ziniao-lock-withdrawn`)。
269
+ # 撤销理由三条:① 它防的不是并发而是「同店操作互相打断」,**而那个判断从来没有真机验证过**;
270
+ # ② 覆盖面只有本机调用方(云端 Agent 走 `browser-cli --ziniao-profile`,够不着 `~/.optima-locks/`);
271
+ # ③ 它在 `status` 里和 `skill-*` 并排显示,**而两者强度完全不同**(L43)。
272
+ #
273
+ # ## 🔴 撤条文和撤执法是两个动作 —— 2026-09-13 只做了第一个
274
+ #
275
+ # 23:xx 宣布撤销时,这里只把「取不到 ⇒ 抛」改成「取不到 ⇒ 印一行照常走」,
276
+ # **`skill-lock acquire` 那一句留着了**。后果是**它继续往共享锁目录落 `skill-ziniao-*.lock`**,
277
+ # 于是同一把锁**两条路给出相反的答案**:
278
+ # · harness 这条路:取不到也不拦你(`lock_busy_not_blocking` 24 次)
279
+ # · **CLI 那条路:直接拒**(`-4` 2026-09-13 17:19Z 实测被拒;`-6` 17:17、`-10` 17:22 取到)
280
+ # 🔴 **而条文说这把锁根本不存在。** —— L43:混在一起的状态比没有状态更坏。
281
+ #
282
+ # ⇒ 2026-09-14:**这里一句 `skill-lock` 都不再调**,`skill-lock` 自己也在 `acquire ziniao-*`
283
+ # 上直接回 `RESULT=WITHDRAWN`(不落文件)。**两条路这才说同一句话。**
284
+ #
285
+ # ## ⚠️ 撤的是锁,**留下来的是这三样**
286
+ #
287
+ # 1. **`ziniao=` 这个必填关键字参数** —— 漏传 **直接 `TypeError`**(L111:坏了就拼不出来)。
288
+ # 它答的是「这一轮要动哪个 profile」,`send()` 要拿它和正文对账。
289
+ # 2. **`ziniao=None` 必须写理由** —— 「我确实不需要」和「我忘了」不许共用取值(L42)。
290
+ # 3. **各开各的 tab(`own_tab=True`),不要接管别人正在用的那个** ——
291
+ # 🔴 **这条纪律没有随锁一起撤**,而它现在只能靠文档和参数说明活着(#662)。
292
+ #
293
+ # ⚠️ **别把「有这个参数」读成「profile 不会被同时开」**:它从来就管不着云端那条路,
294
+ # 而现在连本机这条路也不再互斥 —— **它只是一份声明**。
295
+ #
296
+ # ## ⚠️ 它保证什么 —— **一句都别多说**
297
+ #
298
+ # 🔴 **它连「不同时开」都不保证了**(锁撤之前保证的正是这一条)。
299
+ # 它更**不保证**「一次只读导航对相邻轮次零影响」—— **那条至今没有证据**。
300
+ # 出处是 `-6` 的 #315 轮次账本(2026-09-13),原话:
301
+ # > **我现在没法证明「导航走开又回来」对下一轮零影响** ——
302
+ # > 我只能说这两轮之间没有我的观测被你覆盖。
303
+ #
304
+ # 🔴 **判词里不许出现「只读所以安全」。**
305
+
306
+ # ── 这道闸的流水账(#611,`-4` 2026-09-13 08:50Z 提的那个缺口)────────
307
+ #
308
+ # 🔴 **一道闸只报「我拦了」,不报「它至今拦了多少次」,那么
309
+ # 「它是不是误伤太多」这个问题在结构上就答不了** ——
310
+ # 而那正是「**误伤会让人把守卫关掉**」(L13)发生之前,唯一能救它的那个数。
311
+ #
312
+ # 本闸 2026-09-13 一天之内**误伤三次**(#645 两次 · #666 一次),三次都被人实打实地咬到;
313
+ # 而**它正确拦下过几次,没有任何地方记着**(`skill-lock` 自己也不写任何日志——查过了,
314
+ # `scripts/skill-lock.sh` 135 行里没有一处写日志)。⇒ **分子有人记,分母没有。**
315
+ #
316
+ # ## 🔴 放行也要记(L139)
317
+ #
318
+ # 「我用了哪条判据」是**记账**,不是诊断 —— **成功的时候也要记**。
319
+ # 只记拦下的话,算出来的永远是 100%:**没有分母的比例不是比例。**
320
+ #
321
+ # ## ⚠️ 本账**不答**「拦对了几次」
322
+ #
323
+ # 那要人来判 —— 记录只提供**分母**和**每次拦下的现场**。别把它读成准确率。
324
+ #
325
+ # ## ⚠️ 它绝不许把闸本身弄坏
326
+ #
327
+ # 记账失败一律咽掉:**一条记账用的 I/O 不许成为这道闸的新失败模式**。
328
+ # 代价是「从来没记过」和「记坏了」共用同一种表现(L65)——
329
+ # ⇒ **消费端(`scripts/ziniao-gate-report.py`)对「文件不在」回 `unknown`,不回 `0`**。
330
+ ZINIAO_GATE_LEDGER = os.path.expanduser("~/.optima-locks/ziniao-gate.jsonl")
331
+ ZINIAO_GATE_REPORT_HINT = (
332
+ "(这一次已记进流水账;要看这道闸至今拦了多少次、放行多少次,"
333
+ "跑 `python3 scripts/ziniao-gate-report.py`)")
334
+
335
+
336
+ def _gate_record(event, verdict, **fields):
337
+ """往流水账追加一行。**永不抛** —— 见上面那段。
338
+
339
+ `verdict` 只有两个取值:`"block"`(这一次拦下了)· `"pass"`(这一次放行了)。
340
+ 🔴 **两个取值分开写死**,不从 `event` 现推 —— 推导会让「新加了一种 event
341
+ 但忘了归类」悄悄落进某一格(L32:表里要有出口)。
342
+ """
343
+ assert verdict in ("block", "pass"), verdict
344
+ row = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
345
+ "event": event, "verdict": verdict,
346
+ "clone": os.path.basename(os.getcwd()), "pid": os.getpid()}
347
+ row.update({k: v for k, v in fields.items() if v is not None})
348
+ try:
349
+ os.makedirs(os.path.dirname(ZINIAO_GATE_LEDGER), exist_ok=True)
350
+ with open(ZINIAO_GATE_LEDGER, "a", encoding="utf-8") as fh:
351
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
352
+ except Exception: # noqa: BLE001
353
+ pass
354
+
355
+
356
+ def ziniao_lock_key(profile_id) -> str:
357
+ """→ `ziniao-<profile_id>`:**这一轮的声明键**。
358
+
359
+ ⚠️ 名字里的 `lock` 是历史遗留 —— **锁已经撤了**,这个串现在只有两个用途:
360
+ ① `send()` 拿它和正文点名的 profile 对账;② 流水账里标明这一轮在哪个 profile 上。
361
+
362
+ 🔴 **必须用 profile id,不能用店名**:别名和 id 对不上账,
363
+ `send()` 的对账会把「声明了 A、正文点了 A」判成「声明了别的」。
364
+ """
365
+ pid = str(profile_id).strip()
366
+ if not pid:
367
+ raise ValueError("ziniao profile id 是空的")
368
+ if not re.fullmatch(r"[0-9]{6,}", pid):
369
+ raise ValueError(
370
+ f"ziniao={pid!r} 看起来不是 profile id(应是纯数字,≥6 位)。"
371
+ "**别用店名** —— 别名和 id 对不上账,`send()` 的对账会判错。")
372
+ return f"ziniao-{pid}"
373
+
374
+
375
+ def acquire_ziniao(ziniao, reason=None, what="e2e", _run=None):
376
+ """进 `attach()` 之前的**声明**闸。**→ 两个值,不是一个。**
377
+
378
+ → **`(key, owned)`**:`key` 是「我这一轮在哪个 profile 上」(`send()` 对账用),
379
+ `owned` **恒为 `False`** —— 锁撤了,没有任何东西可以被「持有」,
380
+ 因此也没有任何东西需要在 `close()` 里放。
381
+
382
+ 🔴 **`owned` 为什么不干脆删掉**:它是调用方 `close()` 那条分支的开关,
383
+ 删了要同时改三处调用点;留成恒 `False` 反而让「谁都不许去 release」这件事
384
+ **在返回值上写死**。⚠️ 但它**不许**再长回「有时为真」—— 那意味着锁回来了。
385
+
386
+ · `ziniao="278…"` ⇒ 声明这一轮要驱动哪个 profile。
387
+ 🔴 **它只是声明,不再取任何锁**(2026-09-14 撤执法)。
388
+ **各开各的 tab(`own_tab=True`),不要接管别人正在用的那个** —— 这条纪律没撤。
389
+ · `ziniao=None` + 理由 ⇒ 放行,并把理由打出来(**它会进流水账,供事后核**)
390
+ · `ziniao=None` 无理由 ⇒ **抛 `ValueError`**:「明确不需要」必须写出来,
391
+ 否则它和「忘了传」又共用一个取值。
392
+
393
+ ⚠️ `_run` 参数留着只为兼容既有调用/测试签名 —— **本函数不再调用任何外部命令**。
394
+ """
395
+ if ziniao is None:
396
+ if not (isinstance(reason, str) and reason.strip()):
397
+ raise ValueError(
398
+ "ziniao=None 必须同时写明理由(reason=\"这一轮不碰任何紫鸟 profile,因为…\")。"
399
+ "**「我确实不需要」和「我忘了」不许共用一个取值。**")
400
+ print(f"[ziniao] 显式声明不碰任何 profile:{reason.strip()}", flush=True)
401
+ _gate_record("declared_none", "pass", reason=reason.strip(), what=what)
402
+ return None, False
403
+ key = ziniao_lock_key(ziniao)
404
+ # 🔴 **这里以前有一句 `skill-lock acquire`。它是「撤了条文没撤执法」的那一半。**
405
+ # 它落下的 `skill-ziniao-*.lock` 会让**别人**在 CLI 那条路上被拒
406
+ # —— 本进程自己看不见那个后果,所以它躲了一整天。
407
+ print(f"[ziniao] 本轮声明的 profile:{key}(**这把锁已撤销,不取锁、不拦人**)\n"
408
+ f" ⚠️ **各开各的 tab,别接管别人正在用的那个**(own_tab=True)——"
409
+ f"这条纪律没有随锁一起撤。", flush=True)
410
+ _gate_record("declared", "pass", key=key, what=what)
411
+ return key, False
412
+
413
+
414
+ # 🔴 **「没传」要有自己的取值**(#666 / L42)。
415
+ # 拿 `None` 当默认 ⇒ **「忘了传」会被读成「我明确不碰任何 profile」**,
416
+ # 而那正好是这道闸要抓的那种假声明 —— **默认值不许长得像一个合法声明。**
417
+ _INHERIT = object()
418
+
419
+
420
+ class ZiniaoDeclarationConflict(RuntimeError):
421
+ """**声明说不碰 profile,发出去的正文里却点名了一个** —— 那个声明是假的。"""
422
+
423
+
424
+ # 🔴 **位数是判据的关键**(`-4` 2026-09-13 review 时点出来的):
425
+ # 紫鸟 profile id = **14 位**;货盘 `product_id` = **19 位**。
426
+ # ⇒ 用 `\d{14,}` 会**把商品 ID 全打成 profile** —— 误伤,而**误伤会让人把守卫关掉**。
427
+ # 这里用**前后都不能再有数字**的 14 位,19 位串整个不匹配。
428
+ _PROFILE_IN_TEXT = re.compile(r"(?<!\d)(\d{14})(?!\d)")
429
+ _PROFILE_FLAG = "--ziniao-profile"
430
+
431
+
432
+ def known_profile_ids() -> set:
433
+ """这台机器/这个仓**认识**的紫鸟 profile id。
434
+
435
+ 唯一来源:`e2e/registry*.yaml` 里的 `store:` —— 本仓写下来的真实 profile。
436
+
437
+ ## ⚠️ 2026-09-14:**第二个来源没了,而且是结构性地没了**
438
+
439
+ 原来还从 `skill-lock status` 里捞 `ziniao-*` 已被占的 key。
440
+ **锁撤销之后 `skill-lock acquire ziniao-*` 不再落任何文件** ⇒
441
+ 那条来源**永远返回空**。留着它就是留一个「0 命中」和「方法坏了」
442
+ 长得一模一样的东西(L11),所以删掉,**并把窄下来的覆盖写在这里**。
443
+
444
+ 🔴 **为什么必须有这张表**(`-4` 2026-09-13 真机上被拦住才发现):
445
+ 只按「14 位数字」判会**把时间戳当成 profile** ——
446
+ `Tadaparty_20260912180918` 里的 `YYYYMMDDHHMMSS` **正好 14 位**,
447
+ 而它在本仓到处都是(计划名、导出文件名、日志名)。
448
+ ⚠️ 那次误伤挡住的是一次**操作正确 profile 的真机**。
449
+ **而下一个撞上的人,最省事的办法是把参数改短绕过去 —— 那才是真的绕过了闸。**
450
+
451
+ ⚠️ **它的盲区要写出来**:**两张表都没收录的真 profile,这道闸看不见它**
452
+ (比如新开一家店、还没进 registry)。**不声称没验过的覆盖。**
453
+ ⚠️ 盲区比 2026-09-13 那版**更宽了一格** —— 以前「有人锁着」也算认识,现在不算。
454
+ 兜底只剩 `--ziniao-profile <id>` 那条无歧义写法。
455
+ """
456
+ ids = set()
457
+ try:
458
+ e2e = os.path.join(_repo_root(), "e2e")
459
+ for fn in os.listdir(e2e):
460
+ if fn.startswith("registry") and fn.endswith(".yaml"):
461
+ txt = open(os.path.join(e2e, fn), encoding="utf-8", errors="replace").read()
462
+ ids.update(re.findall(r"store:\s*[\"']?(\d{6,})", txt))
463
+ except OSError:
464
+ pass
465
+ return ids
466
+
467
+
468
+ def _repo_root():
469
+ here = os.path.dirname(os.path.abspath(__file__))
470
+ return os.path.dirname(os.path.dirname(os.path.dirname(here)))
471
+
472
+
473
+ def profiles_named_in(msg, known=None) -> list:
474
+ """一段要发给 Agent 的文本里,点名了哪些紫鸟 profile。
475
+
476
+ **判据故意窄**(只两种形态),因为**误伤会让人把守卫关掉**:
477
+ · 字面 `--ziniao-profile`(云端 `runscript` 那条路的写法)—— **无条件认**
478
+ · **独立的 14 位数字,且它是一个已知的 profile id**(见 `known_profile_ids`)
479
+
480
+ ⚠️ **它抓不到**:用店名指代(「小海店」)· 把 id 拆开写 ·
481
+ 让 Agent 自己去 `ziniao profiles` 里挑 · **两张表都没收录的真 profile**。
482
+ **写下来,别声称没验过的覆盖。**
483
+ """
484
+ text = str(msg or "")
485
+ known = known_profile_ids() if known is None else set(known)
486
+ ids = sorted({x for x in _PROFILE_IN_TEXT.findall(text) if x in known})
487
+ # `--ziniao-profile <id>` 是**无歧义**的写法:它后面跟的数字一律认,不看表。
488
+ for m in re.finditer(re.escape(_PROFILE_FLAG) + r"[=\s]+(\d{6,})", text):
489
+ if m.group(1) not in ids:
490
+ ids.append(m.group(1))
491
+ if _PROFILE_FLAG in text and not ids:
492
+ return ["<--ziniao-profile 但没写出 id>"]
493
+ return sorted(ids)
494
+
495
+
496
+ def check_declaration(declared_key, msg, known=None):
497
+ """**声明与事实对账**:发出去的正文点名的 profile,和 `attach()` 时声明的对得上吗?
498
+
499
+ 🔴 这道闸补的是前两道的固有上限:**显式出口一定允许一个错误的显式声明** ——
500
+ `ziniao=None, reason="只读 dump"` 写得出来,而那一轮真的动了 profile
501
+ (`-4` 2026-09-13 06:14Z 自陈的那次就是这个形态)。
502
+ **声明是 `None`、而正文里带着 profile —— 那一刻它就变成假的了,这时候抓得住。**
503
+
504
+ ⚠️ 它**不是**「保证没动别的 profile」:见 `profiles_named_in` 的盲区。
505
+
506
+ ## 🔴 为什么这一格是**拦**,而 `must_call` 那一格是**留痕**(#666 要求先定的那条)
507
+
508
+ `-5` 问得对:**「声明 ≠ 实际」这一格该留痕还是该拦?**
509
+ **两者不是同一类事,判据是「错了之后谁付代价」:**
510
+
511
+ | | `must_call` 不符 | **本闸**不符 |
512
+ |---|---|---|
513
+ | 发生了什么 | 用例声明不加载 skill,实际加载了 | **正文点名 B,而我们手上是 A 的锁** |
514
+ | 代价 | **测试质量**打折 —— 一条观测不准 | 🔴 **一条消息发给云端 Agent,去动一家不属于本轮的店** |
515
+ | 可撤销吗 | 是(重跑一次) | **否 ——「发出去就收不回来了」** |
516
+
517
+ ⇒ **留痕适用于「结论可能不准」;拦适用于「动作不可撤销」。** 本闸是后者。
518
+
519
+ ⚠️ **而三次误伤都不是这条政策造成的**,逐条核过:
520
+ ① 14 位时间戳被当成 profile id ⇒ **判据错**(#645 已修);
521
+ ② `close()` 放了别人的锁 ⇒ **所有权判据**(#645 已修);
522
+ ③ 点名 profile 的用例全被拦死 ⇒ **那个值有两个家**(#666,本次)。
523
+ **没有一次是「它本该只记一笔却抛了异常」。** ⇒ **政策不动。**
524
+ """
525
+ named = profiles_named_in(msg, known)
526
+ if not named:
527
+ # 🔴 **正文没点名任何 profile ⇒ 不记账。**(分母的定义,L116)
528
+ # 这一格不是「放行」,是**这道闸这一次没有可判的东西** ——
529
+ # 把它记进去,分母就被每一条普通消息灌满,**算出来的比例不再是这道闸的比例**。
530
+ # ⇒ 记账的一次 = **它真的做了一次判断**。
531
+ return
532
+ if declared_key is None:
533
+ _gate_record("false_declaration", "block", named=named)
534
+ raise ZiniaoDeclarationConflict(
535
+ f"attach() 时声明了 `ziniao=None`(这一轮不碰任何 profile),"
536
+ f"但要发的正文里点名了 {named} —— **那个声明是假的**。\n"
537
+ f"⇒ 要么改成 `ziniao=<那个 id>` 真去取锁,要么别在正文里点名 profile。\n"
538
+ f"{ZINIAO_GATE_REPORT_HINT}")
539
+ want = declared_key.split("ziniao-", 1)[-1]
540
+ other = [n for n in named if n != want]
541
+ if other:
542
+ _gate_record("wrong_profile", "block", key=declared_key, named=named)
543
+ raise ZiniaoDeclarationConflict(
544
+ f"锁取的是 {declared_key},正文里却点名了 {other} —— **拿着 A 的锁去开 B**。\n"
545
+ f"{ZINIAO_GATE_REPORT_HINT}")
546
+ # 🔴 **放行也要记**(L139)—— 只记拦下的话,算出来的永远是 100%。
547
+ _gate_record("consistent", "pass", key=declared_key, named=named)
548
+
549
+
61
550
  class ChatDriver:
62
551
  def __init__(self, port: int = 9222):
63
552
  self.port = port
@@ -65,7 +554,9 @@ class ChatDriver:
65
554
  self.browser = None
66
555
  self.page = None
67
556
  self.session_id = None # 本 tab 认领的 gateway sessionId(wire 归因/并行隔离校验都靠它)
68
- self._own_tab = False # 这个 tab 是我开的吗 —— close() 只关自己开的,绝不关用户的
557
+ self._own_tab = False
558
+ self._keep_for_human = None # 非 None ⇒ 这一轮停手交人,tab 留着(CEO 那一格)
559
+ self._ziniao_key = None # 这一轮声明在哪个 profile 上(send() 的对账用;**不是锁**)
69
560
  self.tab_isolated = False # 是否真独占了一个 gateway session(并行的前提,降级后为 False)
70
561
  self._tool_baseline = 0 # 发送前的「個工具」面板数;本轮只抓之后新增的(防超时用例污染下一轮)
71
562
  self._console_errs = [] # 前端 console 报错缓冲——区分「傳送失敗」的真实根因(weekly_limit vs credits)
@@ -89,9 +580,43 @@ class ChatDriver:
89
580
  self.page.wait_for_timeout(1000)
90
581
  return None
91
582
 
92
- def attach(self, own_tab="auto", claim_timeout: int = 60) -> "ChatDriver":
583
+ def attach(self, *, ziniao, reason=None, own_tab="auto",
584
+ claim_timeout: int = 60) -> "ChatDriver":
93
585
  """连上调试端口 Chrome 并选定本 driver 要驱动的 tab。
94
586
 
587
+ 🔴 `ziniao` 是**必填关键字参数**(#611)——漏传直接 `TypeError`,
588
+ **不是警告、不是默认值**。它答的是「这一轮要动哪个紫鸟 profile」:
589
+
590
+ - `ziniao="27884995544032"` ⇒ **声明**这一轮要驱动哪个 profile
591
+ (🔴 **不取任何锁** —— `ziniao-<id>` 这把锁 2026-09-14 撤了执法,见模块上方那段)
592
+ - `ziniao=None, reason="…"` ⇒ **显式**声明这一轮不碰任何 profile(理由必填)
593
+
594
+ **「我确实不需要」和「我忘了」不共用取值** —— 后者是 `TypeError`。
595
+ 🔴 **没有锁要放** ⇒ `close()` 里那半也一起没了。
596
+
597
+ ## 🔴 这个声明**管谁、不管谁**(写在这里,别只写在 PR 里)
598
+
599
+ 穷举过所有能驱动一个紫鸟 profile 的入口(#611):
600
+
601
+ | 入口 | 在哪跑 | 本闸拦不拦 |
602
+ |---|---|---|
603
+ | e2e 逐条用例(`case["store"]`) | 本机 | ✅ 在 `run.run_case` 那一层声明 |
604
+ | **直接 `ChatDriver().attach()`**(人/脚本) | 本机 | ✅ **就是这个参数** |
605
+ | 🔴 云端 Agent 跑 marketplace skill:`browser-cli … --ziniao-profile` | **云端 pod** | ❌ **看不见** |
606
+ | 人在终端直接敲 `browser-cli --ziniao-profile` | 本机 | ❌ 看不见 |
607
+
608
+ 🔴 **量**:20/34 份 SKILL.md 教 Agent 传 `--ziniao-profile` ——
609
+ **绝大多数真正驱动 profile 的流量走的是云端那条路,而 pod 上没有
610
+ `~/.optima-locks/`,这把锁在那条路上结构性够不着。**
611
+
612
+ ⚠️ **所以别把「有这道闸」读成「profile 不会被同时开」。**
613
+ 它盖住的是**本机调用方**;云端那条要互斥,得在服务端有东西执法,**不在本仓**。
614
+
615
+ ⚠️ 另:e2e 主路径上 `attach()` 收到的**恒是 `ziniao=None`**
616
+ (worker 的 tab 不绑定 profile,profile 在逐条用例那层声明)——
617
+ **也就是说这个必填参数在主路径上永远不被真正行使**。
618
+ 它挡的是**别的调用方**。**知道这一点再决定要不要依赖它。**
619
+
95
620
  `own_tab` 三态 —— **默认自己开 tab**(平台既然支持多 tab,独占就是常态,共用才是例外):
96
621
 
97
622
  - `"auto"`(默认):先试着自己开 tab 独占一个 gateway session;该环境不支持多 tab
@@ -106,6 +631,13 @@ class ChatDriver:
106
631
  `self.session_id` 记认领到的 gateway sessionId(Wire 归因锚点);
107
632
  `self.tab_isolated` 说明这个 driver 是不是真独占了一个 session。
108
633
  """
634
+ # 🔴 闸在**开浏览器之前**(L17:位置不对等于没有)——
635
+ # 声明没写全(漏传 / `None` 无理由)就不该有任何动作发生。
636
+ # ⚠️ 第二个返回值**恒为 `False`**(锁撤了,没有可持有的东西)⇒ 直接丢掉,
637
+ # **不要再落一个 `self._ziniao_owned`** —— 那个字段是 `close()` 放锁的开关,
638
+ # 留着它等于把锁的接口留在那儿等人接回去。
639
+ self._ziniao_key, _ = acquire_ziniao(
640
+ ziniao, reason, what=f"e2e attach own_tab={own_tab}")
109
641
  strict = (own_tab is True)
110
642
  want_own = (own_tab is True or own_tab == "auto")
111
643
  self._pw = sync_playwright().start()
@@ -167,8 +699,54 @@ class ChatDriver:
167
699
  self.page.on("console", lambda m: self._console_errs.append((m.text or "")[:200])
168
700
  if m.type in ("error", "warning") else None)
169
701
 
702
+ # ── 收尾:这一轮开的 tab,这一轮关掉 ────────────────────────────────
703
+ #
704
+ # 🔴 **CEO 2026-09-13 夜发来紫鸟截图:24 个 tab。** 原话:
705
+ # 「所有的 skills、所有的任务都只会新建 tab 而不关闭 tab,紫鸟就是越开 tab 越多」
706
+ #
707
+ # ⚠️ **`close()` 本来就会关自己那个 tab,问题从来不在这里** ——
708
+ # 在于**调用方**:临时脚本写 `d.close()` 在函数末尾,**一抛异常就漏**,
709
+ # 🔴 **而失败轮次恰恰最多。**(本文件作者自己今晚漏过两个。)
710
+ # ⇒ 所以加的不是「再关一次」,是**让漏掉变难**:`with` 一定关。
711
+ #
712
+ # 🔴 **CEO 亲自留的那一格**:
713
+ # 「任务完成后就应该把它关掉;**除非需要用户手动介入去点击,才应该保留**。」
714
+ # ⇒ 判据:**这一轮是不是给店主留了一句「你去点一下」?是 ⇒ 留;否 ⇒ 关。**
715
+ # 用 `keep_tab_for_human("为什么")` 声明,**理由必填** ——
716
+ # 「确实要留给人」和「忘了关」不许共用一个取值。
717
+
718
+ def keep_tab_for_human(self, reason: str) -> None:
719
+ """声明这一轮**停手交人**,tab 留着不关(CEO 定的那一格)。
720
+
721
+ 🔴 **理由必填**:留一个 tab 是要占并发名额的,
722
+ 「确实要留给人」和「忘了关」**不许共用一个取值**。
723
+ """
724
+ if not (isinstance(reason, str) and reason.strip()):
725
+ raise ValueError(
726
+ "keep_tab_for_human(reason=...) 的理由必填 —— "
727
+ "**「要留给店主点」和「忘了关」不许共用一个取值**。"
728
+ "例:keep_tab_for_human('停在验证码,要店主本人过')")
729
+ self._keep_for_human = reason.strip()
730
+ print(f"[tab] 这一轮**留着不关**(停手交人):{reason.strip()}", flush=True)
731
+
732
+ def __enter__(self):
733
+ return self
734
+
735
+ def __exit__(self, exc_type, exc, tb):
736
+ # 🔴 **异常退出也要关** —— 只在成功路径上关,等于「失败轮次的 tab 永远留着」。
737
+ self.close()
738
+ return False # 不吞异常
739
+
170
740
  def _close_own_tab(self) -> None:
171
- """只关本 driver 自己开的 tab(降级/收尾共用)。"""
741
+ """只关本 driver 自己开的 tab(降级/收尾共用)。
742
+
743
+ ⚠️ **只关自己这一轮开的那个**(`self._own_tab`)——
744
+ 🔴 **绝不去关别人正在用的**:十个 session 并发,
745
+ 「顺手清理陈旧 tab」会关掉别人的那一个,而那种误伤没有任何东西拦得住。
746
+ """
747
+ if getattr(self, "_keep_for_human", None):
748
+ print(f"[tab] 保留(停手交人):{self._keep_for_human}", flush=True)
749
+ return
172
750
  if self._own_tab and self.page:
173
751
  try:
174
752
  self.page.close()
@@ -185,8 +763,13 @@ class ChatDriver:
185
763
  if self.browser:
186
764
  self.browser.close()
187
765
  finally:
188
- if self._pw:
189
- self._pw.stop()
766
+ try:
767
+ if self._pw:
768
+ self._pw.stop()
769
+ finally:
770
+ # ⚠️ 这里以前放锁。**锁撤了(2026-09-14)⇒ 没有任何东西要放。**
771
+ # 只把声明清掉,免得下一次 attach 之前 `send()` 读到上一轮的 key。
772
+ self._ziniao_key = None
190
773
 
191
774
  def _eval(self, js: str, arg=None):
192
775
  return self.page.evaluate(js, arg) if arg is not None else self.page.evaluate(js)
@@ -196,6 +779,21 @@ class ChatDriver:
196
779
  """当前是否有对话正在流式生成。派生自 chat_state(单一真相)。"""
197
780
  return self.chat_state() == "generating"
198
781
 
782
+ # #1635:#1197 B1 真机——两问 AskUserQuestion 卡片,chat_state 判到 waiting_input,read_question 却回 '',
783
+ # 预设 answers 永不触发、5 分钟后 gateway 判超时。那次没留 DOM dump,**真实形态没探到**;候选形态有三种,
784
+ # 这里一并盖住:(a) 已渲染的活卡 innerText 为 ''(祖先 visibility:hidden 之类,rect>0 但 innerText 空);
785
+ # (b) 两次 _eval 之间 DOM 被换掉/重挂;(c) 页面上有**多张** question-card(消息流里内联的历史只读卡 #161B3 T6 无提交按钮
786
+ # + 当前活卡)而老代码四处各取「第一张可见的卡」。
787
+ # ⇒ 活卡的定义只有一个:**卡内有 確認/下一題/補充回答 按钮**(多张取最后一张——DOM 序历史在前、当前在后);
788
+ # 找不到活卡再退到最后一张可见卡。所有读/答/关都用同一条判据。
789
+ _CARD_PICK_JS = r"""
790
+ const cards=[...document.querySelectorAll('[data-testid="question-card"]')];
791
+ const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
792
+ const isLive=c=>[...c.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()));
793
+ const liveCards=cards.filter(isLive);
794
+ const card=liveCards[liveCards.length-1] || cards.filter(vis).pop() || null;
795
+ """
796
+
199
797
  def has_pending_question(self) -> bool:
200
798
  """Agent 是否停在「需要你的輸入」(AskUserQuestion 弹问)。派生自 chat_state。
201
799
  这个状态既非「生成中」也非「结束」,若不处理,下一个用例会串进这个卡住的对话。"""
@@ -203,9 +801,7 @@ class ChatDriver:
203
801
 
204
802
  def dismiss_pending_question(self) -> bool:
205
803
  """关掉待输入问题框(点卡内 取消/關閉),让对话回到可开新对话的状态。返回是否点到。"""
206
- clicked = bool(self._eval(r"""()=>{
207
- const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
208
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
804
+ clicked = bool(self._eval("()=>{" + self._CARD_PICK_JS + r"""
209
805
  if(!card) return false;
210
806
  const btn=[...card.querySelectorAll('button')].find(e=>/^(取消|關閉|关闭)$/.test((e.textContent||'').trim()) && vis(e) && !e.disabled);
211
807
  if(btn){btn.click(); return true;}
@@ -216,12 +812,28 @@ class ChatDriver:
216
812
  return clicked
217
813
 
218
814
  def read_question(self) -> str:
219
- """读当前 AskUserQuestion 卡片全文(问题+选项标题+按钮)——供 _pick_answer 匹配。"""
220
- return self._eval(r"""()=>{
221
- const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
222
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
223
- return card ? (card.innerText||'').slice(0,1200) : '';
224
- }""") or ""
815
+ """读当前 AskUserQuestion 卡片全文(问题+选项标题+按钮)——供 _pick_answer 匹配。
816
+
817
+ #1635:**不按可见性过滤**(可见性留给 answer_question 点按钮那步)——活卡先 `scrollIntoView` 再读,
818
+ `innerText` 空就退到 `textContent`(祖先 visibility:hidden 时 innerText 为空、textContent 仍在);
819
+ 没有活卡就把**所有** question-card 的文本合并(历史只读卡也算,宁可多匹配也别空串)。
820
+ 读到的来源记在 `self.last_question`(`source ∈ live|all_cards|none`、`n_cards`、`text`),
821
+ wait_reply 会把它带进返回值 —— **「读不到」和「没问」不许长得一样**。"""
822
+ got = self._eval("()=>{" + self._CARD_PICK_JS + r"""
823
+ const text=e=>(e.innerText||e.textContent||'').trim();
824
+ if(card && isLive(card)){ // 只有真活卡才单读;退回来的「最后一张可见卡」走合并
825
+ try{ card.scrollIntoView({block:'nearest'}); }catch(e){} // nearest:别把 react-window 的自动跟随关掉
826
+ const t=text(card);
827
+ if(t) return {source:'live', n_cards:cards.length, text:t.slice(0,1200)};
828
+ }
829
+ const all=cards.map(text).filter(Boolean).join('\n---\n');
830
+ if(all) return {source:'all_cards', n_cards:cards.length, text:all.slice(-1200)};
831
+ return {source:'none', n_cards:cards.length, text:''};
832
+ }""") or {"source": "none", "n_cards": 0, "text": ""}
833
+ if not isinstance(got, dict):
834
+ got = {"source": "none", "n_cards": 0, "text": str(got or "")}
835
+ self.last_question = got
836
+ return got.get("text") or ""
225
837
 
226
838
  def answer_question(self, text: str) -> bool:
227
839
  """给当前 AskUserQuestion **回答并提交** —— 真跟鸭嘴兽对话往下走(不是关掉)。
@@ -229,11 +841,10 @@ class ChatDriver:
229
841
  ① 选项题:`text` 与某选项标题互含 → 点该选项行;否则点「其它」展开 textarea 填 text;
230
842
  ② 开放题(0 选项):直接有 textarea → 填 text。
231
843
  再点「下一題」(非末题) 或「確認/補充回答」(末题) 提交。返回提交动作是否真的点了。"""
232
- acted = self._eval(r"""(t)=>{
233
- const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
844
+ acted = self._eval("(t)=>{" + self._CARD_PICK_JS + r"""
234
845
  const norm=s=>(s||'').replace(/\s+/g,'').toLowerCase();
235
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
236
846
  if(!card) return 'no-card';
847
+ try{ card.scrollIntoView({block:'nearest'}); }catch(e){}
237
848
  const nt=norm(t);
238
849
  // 选项行 = 卡内 cursor:pointer 的 div(OptionRow),标题在 .font-medium
239
850
  const rows=[...card.querySelectorAll('div')].filter(e=>{
@@ -264,18 +875,14 @@ class ChatDriver:
264
875
  self.page.wait_for_timeout(700)
265
876
  # 若刚点开「其它」,textarea 才出现 → 再填一次
266
877
  if acted == "other-opened":
267
- self._eval(r"""(t)=>{
268
- const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
269
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
878
+ self._eval("(t)=>{" + self._CARD_PICK_JS + r"""
270
879
  const ta=card && [...card.querySelectorAll('textarea')].find(vis);
271
880
  if(ta){const set=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
272
881
  ta.focus(); set.call(ta,t); ta.dispatchEvent(new Event('input',{bubbles:true}));}
273
882
  }""", text)
274
883
  self.page.wait_for_timeout(500)
275
884
  # 提交:末题点 確認/補充回答,非末题点 下一題(都在卡内、非取消/關閉、未 disabled)
276
- submitted = bool(self._eval(r"""()=>{
277
- const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
278
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
885
+ submitted = bool(self._eval("()=>{" + self._CARD_PICK_JS + r"""
279
886
  if(!card) return false;
280
887
  const btns=[...card.querySelectorAll('button')].filter(e=>vis(e) && !e.disabled);
281
888
  const b=btns.find(e=>/^(確認|确认|補充回答|补充回答)$/.test((e.textContent||'').replace(/[✓\s]/g,'')))
@@ -334,8 +941,10 @@ class ChatDriver:
334
941
  return self._eval(r"""()=>{
335
942
  const vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0;};
336
943
  // ① 待输入:question-card 且**可答**(卡内有 確認/下一題/補充回答 按钮;readOnly 历史卡无按钮=不算)
337
- const card=[...document.querySelectorAll('[data-testid="question-card"]')].find(vis);
338
- if(card && [...card.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()))) return 'waiting_input';
944
+ // #1635:多张卡时看**任何一张**可见活卡,不是「第一张可见卡恰好有按钮」(第一张可能是历史只读卡)
945
+ const cards=[...document.querySelectorAll('[data-testid="question-card"]')];
946
+ const isLive=c=>[...c.querySelectorAll('button')].some(b=>/確認|确认|下一題|下一题|補充回答|补充回答/.test((b.textContent||'').trim()));
947
+ if(cards.some(c=>vis(c) && isLive(c))) return 'waiting_input';
339
948
  // ② 生成中:stop-button 在(权威信号,优先于残留错误 toast)
340
949
  if([...document.querySelectorAll('[data-testid="stop-button"]')].some(vis)) return 'generating';
341
950
  // ③ 报错 toast:仅在**既不待输入也不生成**时才当真(自身文本短=真 toast)
@@ -429,15 +1038,22 @@ class ChatDriver:
429
1038
 
430
1039
  def preflight(self, timeout: int = 150) -> dict:
431
1040
  """测试前置预检(每轮必做):确认桌面客户端连着**正确账号**、能列出可操作的紫鸟店。
432
- 桌面默认连的可能是别的账号 → 连店类 skill 会「桌面应用未连接」。返回 {ok, stores, reply}。"""
1041
+
1042
+ 判据取 `browser-cli ziniao doctor` 的权威输出,不解析自由文本(见 _PREFLIGHT_PROMPT 的注释)。
1043
+ 返回 {ok(三态), stores, shop_count, reply, scan_chars}。"""
433
1044
  self.goto_tab("AI 助手")
434
1045
  self.new_conversation()
435
- r = self.send_and_wait("先别执行任何店铺操作。请确认桌面客户端连接是否正常,并列出现在能操作的 TikTok 紫鸟店铺(Profile ID + 店铺名)。", timeout)
1046
+ r = self.send_and_wait(_PREFLIGHT_PROMPT, timeout)
436
1047
  txt = r["text"]
437
- # 店名别写死后缀(旧账号店名带「货盘」,换账号就不带了):ID 后面跟到行尾/分隔符的即店名
438
- stores = __import__("re").findall(r"(27\d{11,12})\s*[\t|::、 ]*([^\n\t|]{2,40})", txt)
439
- ok = bool(stores) # 能列出可操作店 = 桌面连着且账号对(Agent 回复可能顺带提「未连接」作说明,不据此判)
440
- return {"ok": ok, "stores": stores, "reply": txt[:600]}
1048
+ res = parse_preflight(txt)
1049
+ if res["ok"] is None: # 没抓到 → 再抓一次页面文本(不重发消息)
1050
+ self.page.wait_for_timeout(3000)
1051
+ txt2 = self._main_panel_text() or ""
1052
+ if len(txt2) > len(txt):
1053
+ txt = txt2
1054
+ res = parse_preflight(txt)
1055
+ res.update({"reply": txt[:600], "scan_chars": len(txt)})
1056
+ return res
441
1057
 
442
1058
  def new_conversation(self) -> bool:
443
1059
  """开新对话,隔离每个测试用例。开新对话是**图标按钮**(文本空、靠 aria-label「新建對話」),
@@ -482,14 +1098,47 @@ class ChatDriver:
482
1098
  return bool(ok)
483
1099
 
484
1100
  # ── 发消息 + 等回复 ──
1101
+ # 🔴 `panel_found` 独立一格(#676):**「面板不在」和「面板里 0 个工具」不许共用 `count: 0`。**
1102
+ # `False` ⇒ 这一轮的**工具维度整个作废**(不 pass 不 fail —— 它没观测到任何东西,
1103
+ # 而 pass 和 fail 都是断言),**其余维度照常判**。
485
1104
  _EMPTY_TRACE = {"tools": [], "count": 0, "names": [], "failures": 0, "failed_tools": [], "repeats": {},
486
- "script_failed": 0, "script_ok": 0}
1105
+ "panel_found": False,
1106
+ "script_failed": 0, "script_ok": 0,
1107
+ "script_last": None, "script_scan_chars": 0, "risk_hits": 0,
1108
+ "login_states": [], "session_limit_hits": 0,
1109
+ "session_limit_mentions": 0}
487
1110
 
488
- def send(self, msg: str) -> bool:
1111
+ def send(self, msg: str, *, ziniao=_INHERIT) -> bool:
489
1112
  """发消息,并**验证真的发出去了**(状态离开 idle → generating/waiting,或输入框清空+消息上屏)。
490
1113
  发不出去(仍 idle 且没上屏)返回 False —— 不再「填了字就以为发出去了」。
491
1114
  ⚠️ **发之前强制 ensure_idle**:本 tab 的对话都是本 driver 发起的,上一个 turn 没结束就发下一个 =
492
- 往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。"""
1115
+ 往忙着的对话里塞消息 → concurrent/傳送中卡死。从代码上根绝——绝不往非 idle 的对话发。
1116
+
1117
+ ## `ziniao=` —— **这一条消息**要动哪个 profile(#666)
1118
+
1119
+ 🔴 **`-3` 给的判据**:**「需要备份还原,就说明那个值待在了不属于它的地方。」**
1120
+
1121
+ 改之前:`run_case` 把本用例的 key **挂到 driver 上**,`send()` 去 `self._ziniao_key` 取,
1122
+ 用完**还原**。⇒ **那个事实有两个家**,两家之间靠手工同步连着,**每次同步都是一条新缝**:
1123
+ 一天之内那道闸误伤三次,**三次是同一个层级错位的三个出口**。
1124
+
1125
+ 改之后:**谁决定谁传,读的人不去别处取。**
1126
+
1127
+ | 传什么 | 意思 |
1128
+ |---|---|
1129
+ | `ziniao="ziniao-<id>"` / `"<id>"` | **这一条**消息动的是这个 profile |
1130
+ | `ziniao=None` | **这一条**消息明确不碰任何 profile |
1131
+ | **不传** | 用 `attach()` 时那个声明当**默认值** |
1132
+
1133
+ ⚠️ **「不传」和 `None` 不共用取值**:`None` 是一个**声明**,不传是**沿用默认**。
1134
+ 这就是为什么默认值是 `_INHERIT` 这个哨兵,而**不是 `None`**
1135
+ (`None` 当默认 ⇒ 「忘了传」会被读成「我明确不碰」——**最危险的那种默认**)。
1136
+ """
1137
+ # 🔴 第三道闸(#611,`-4` review 提的):**声明与事实对账**。
1138
+ # 前两道挡的是「忘了传」;这一道挡的是「**传了一个假的**」——
1139
+ # 而那正是三次真实越锁里两次的形态。**排在最前面:发出去就收不回来了。**
1140
+ key = getattr(self, "_ziniao_key", None) if ziniao is _INHERIT else ziniao
1141
+ check_declaration(key, msg)
493
1142
  if not self.ensure_idle(180):
494
1143
  self._last_send_fail = "concurrent" # 上一个 turn 迟迟不结束 → 别硬发
495
1144
  return False
@@ -570,6 +1219,7 @@ class ChatDriver:
570
1219
  stable = 0
571
1220
  tail = ""
572
1221
  end_state = "timeout"
1222
+ question_seen = None
573
1223
  while time.time() - start < timeout:
574
1224
  time.sleep(3)
575
1225
  st = self.chat_state()
@@ -578,8 +1228,17 @@ class ChatDriver:
578
1228
  break
579
1229
  if st == "waiting_input":
580
1230
  q = self.read_question()
1231
+ question_seen = dict(getattr(self, "last_question", None) or {"source": "none", "n_cards": 0, "text": ""})
1232
+ if not q:
1233
+ # #1635:卡片文本读不到(虚拟列表把它卸了 / 结构变了)⇒ 至少拿页面尾部去匹配,别让预设答案永不触发;
1234
+ # 同时把 source 记成 body_tail —— 「读不到」和「没问」不许长得一样
1235
+ q = (self._eval("()=>document.body.innerText||''") or "")[-1500:]
1236
+ question_seen = {"source": "body_tail" if q else "none", "n_cards": question_seen.get("n_cards", 0), "text": q[-1200:]}
581
1237
  ans = self._pick_answer(q, answers)
1238
+ question_seen["matched"] = ans # 预设答案里匹配到了哪条(None = 没匹配到)
1239
+ question_seen["answered"] = None # 只有 answer_question 真的提交了才记 —— 「匹配到但没答上」≠「答了」
582
1240
  if ans is not None and self.answer_question(ans):
1241
+ question_seen["answered"] = ans
583
1242
  stable = 0
584
1243
  prev = "" # 回答后 Agent 继续,重置稳定判定
585
1244
  continue
@@ -610,16 +1269,18 @@ class ChatDriver:
610
1269
  "elapsed": round(time.time() - start, 1),
611
1270
  "timed_out": end_state == "timeout",
612
1271
  "state": end_state,
1272
+ "question": question_seen, # #1635:停在 waiting_input 时这里是卡片原文 + 来源,None = 这一轮没问过
613
1273
  "tool_trace": self._scrape_tool_trace(),
614
1274
  }
615
1275
 
616
- def send_and_wait(self, msg: str, timeout: int = 180, answers=None) -> dict:
617
- if not self.send(msg):
1276
+ def send_and_wait(self, msg: str, timeout: int = 180, answers=None, *, ziniao=_INHERIT) -> dict:
1277
+ # ⚠️ **原样透传**,不在这儿做任何判断 —— 多一层解释就多一个家(#666)。
1278
+ if not self.send(msg, ziniao=ziniao):
618
1279
  # 消息没真发出去(对话忙/并发锁未释放/积分耗尽/报错)—— 别再 wait 一堆残留内容。
619
1280
  # state 区分 credits(积分不足)/ concurrent(前一 turn 未释放)/ service_error / send_failed,judge 据此报清楚。
620
1281
  return {"text": "", "transcript": "", "tail": "", "elapsed": 0,
621
1282
  "timed_out": False, "state": getattr(self, "_last_send_fail", None) or "send_failed",
622
- "tool_trace": dict(self._EMPTY_TRACE)}
1283
+ "question": None, "tool_trace": dict(self._EMPTY_TRACE)}
623
1284
  return self.wait_reply(timeout, answers)
624
1285
 
625
1286
  def stop_generating(self) -> None:
@@ -642,6 +1303,11 @@ class ChatDriver:
642
1303
  网页结构(实测):每次 Agent 回复带一个可展开 button「已完成 N 個工具」,展开后每个 tool 一行
643
1304
  = <name> <status> [參數][結果](name 如 bash/write/read;status 已完成/失敗/進行中)。"""
644
1305
  # 只看**最后一条回复**的工具面板(当前测试用例;用例间应 new_conversation 隔离)
1306
+ # 🔴 **先回答「面板在不在」,再回答「里面有几个工具」**(#676)。
1307
+ # 原来 `if(!last) return []` —— **「页面上找不到那个聚合面板」和「面板里一行工具都没有」
1308
+ # 回的是同一个值**,于是都变成 `count: 0`。**而「工具 0 次」正是「编了一个答案」的签名。**
1309
+ # 2026-09-13 量过:299 份 per-case 日志里 26 份是「跑了但报 0」,**而这两种分不开。**
1310
+ self._panel_found = bool(self._eval(r"""()=>{const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));return bs.length>0;}"""))
645
1311
  self._eval(r"""()=>{const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));const last=bs[bs.length-1];if(last&&last.getAttribute('aria-expanded')==='false')last.click();}""")
646
1312
  self.page.wait_for_timeout(800)
647
1313
  tools = self._eval(r"""()=>{
@@ -670,22 +1336,52 @@ class ChatDriver:
670
1336
  # ✗ Script failed = 脚本任务失败; ✓ Script <...> = 脚本完成(completed 及各 skill 名变体)。
671
1337
  # ⚠️ 只在这里就地读、**只回整数计数、不回结果原文**——结果体里可能含 env 明文密钥/JWT(gw#2350),
672
1338
  # 绝不落进 transcript/日志(transcript 已在本方法调用前抓定,此处展开不会回灌它)。
673
- script_failed = script_ok = 0
1339
+ script_failed = script_ok = script_check_failed = risk_hits = 0
1340
+ login_states = []
1341
+ session_limit_hits = 0
1342
+ session_limit_mentions = 0
674
1343
  try: # 结果体扫描出任何岔子都只降为 0,绝不让整个 tool_trace 崩掉(它喂所有用例判定)
675
1344
  self._eval(r"""()=>{
676
1345
  const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
677
- const last=bs[bs.length-1]; const panel=last?last.parentElement:document;
1346
+ // 🔴 **面板不在就什么都不做** —— 原来回退到 `document`(整页),
1347
+ // 于是 `risk_hits`/`login_states`/`session_limit_hits` 会从**Agent 自己那段回复**里数出来。
1348
+ // **「没读到」被变成了「读了别的东西」,而后者给出一个自信的错答案。**
1349
+ const last=bs[bs.length-1]; if(!last) return; const panel=last.parentElement;
678
1350
  for(const b of panel.querySelectorAll('button')){ if(/^(結果|结果)$/.test((b.textContent||'').trim())) b.click(); }
679
1351
  }""")
680
1352
  self.page.wait_for_timeout(700)
681
1353
  res_text = self._eval(r"""()=>{
682
1354
  const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
683
- const last=bs[bs.length-1]; return last? (last.parentElement.innerText||'') : (document.body.innerText||'');
1355
+ // 🔴 **面板不在 回空串,不回整页**(同上)。空串会让下面每个计数都是 0,
1356
+ // 而 `panel_found=False` 会把它们一起标成 `unknown` —— **0 不再冒充观测值**。
1357
+ const last=bs[bs.length-1]; return last? (last.parentElement.innerText||'') : '';
684
1358
  }""") or ""
1359
+ # 🔴 `✓ Script` 会命中 **submit-script 的上传回执**(`✓ Script <name> created/updated`),
1360
+ # 那不是任务完成 —— 而浏览器类 skill 的标准流程第一步就是 submit-script,
1361
+ # 于是 script_ok 恒 >0,「failed>0 且 ok==0」这条降级条件**几乎永不成立**(#241)。
1362
+ # 任务完成的真实回执只有一种:`✓ Script completed`。
1363
+ # 脚本**自报的校验不过**:`✓ Script completed` 但结果里写着「校验=不过」或 ABORT。
1364
+ # #295 实证:add_to_discount 填完回读对不上(填 15%、读回别的商品的 30),
1365
+ # 结果字符串里明明白白写着「校验=不过」,而三维判定只看 ✓/✗ 和关键词 → 判了 pass。
1366
+ script_check_failed = res_text.count("校验=不过") + res_text.count("ABORT ")
685
1367
  script_failed = res_text.count("✗ Script failed")
686
- script_ok = res_text.count("✓ Script")
1368
+ script_ok = res_text.count("✓ Script completed")
1369
+ # 只数次数还不够:失败后重试成功要算成功,成功后重试失败要算失败 ——
1370
+ # 看的是**最后一次**的结局,不是谁多谁少。
1371
+ i_ok = res_text.rfind("✓ Script completed")
1372
+ i_bad = res_text.rfind("✗ Script failed")
1373
+ script_last = ("ok" if i_ok > i_bad else "failed") if (i_ok >= 0 or i_bad >= 0) else None
1374
+ # 抓取失效守卫(#26):结果体一个字都没读到时,上面三个数全是「没发现问题」的样子,
1375
+ # 与「真的没问题」不可区分。把扫描长度带出去,让判定方自己决定信不信。
1376
+ script_scan_chars = len(res_text)
1377
+ # 平台侧风控证据(#331):只认脚本失败回执里自己说撞了风控的那种,只回计数。
1378
+ risk_hits = count_platform_risk_receipts(res_text)
1379
+ login_states += scrape_login_states(res_text)
1380
+ session_limit_hits += count_session_limit_receipts(res_text)
1381
+ session_limit_mentions += count_session_limit_mentions(res_text)
687
1382
  except Exception: # noqa: BLE001
688
1383
  pass
1384
+ skill_src = self._scrape_skill_source_edits()
689
1385
  return {
690
1386
  "tools": tools, # [{name,status}] 完整顺序
691
1387
  "count": len(tools),
@@ -693,10 +1389,76 @@ class ChatDriver:
693
1389
  "failures": len(failures),
694
1390
  "failed_tools": [t["name"] for t in failures],
695
1391
  "repeats": repeats, # {工具名: 次数},看有没有反复
1392
+ # 🔴 **这一轮到底有没有那个面板**(#676)。`False` ⇒ 上面每个计数都是
1393
+ # 「没读到」,**不是「读到了 0」** —— 消费者要据此把工具维度作废。
1394
+ "panel_found": self._panel_found,
696
1395
  "script_failed": script_failed, # stdout 里 `✗ Script failed` 次数(任务级失败)
697
- "script_ok": script_ok, # stdout 里 `✓ Script …` 次数(任务级完成)
1396
+ "script_ok": script_ok, # stdout 里 `✓ Script completed` 次数(**任务级**完成)
1397
+ "script_last": script_last, # 最后一次脚本任务的结局:"ok" / "failed" / None
1398
+ "script_scan_chars": script_scan_chars, # 结果体扫到的字符数;0 = 没抓到,别信上面三个数
1399
+ "script_check_failed": script_check_failed, # 脚本自报「校验=不过」/ABORT 的次数
1400
+ # 平台侧风控停手回执数(#331)。**0 不代表没撞风控**,只代表没拿到平台侧证据——
1401
+ # 判定方须按三态处理(run.py `classify_risk_control`),不许默认落到 blocked。
1402
+ "risk_hits": risk_hits,
1403
+ "login_states": login_states,
1404
+ # #671:**挨着失败回执**的计数(唯一允许判 hit 的取数)
1405
+ "session_limit_hits": session_limit_hits,
1406
+ # #671:**总出现数**(含文档/源码/复述)。🔴 不是证据,只许走 unknown。
1407
+ # 两者都留着,是为了让「收窄之后一次都不响」和「本来就没出现过」分得开。
1408
+ "session_limit_mentions": session_limit_mentions,
1409
+ # Agent 中途改了自己加载的 skill 源码(#277 实证)——这轮跑通的不是发布版
1410
+ "skill_src_edits": skill_src["count"],
1411
+ "skill_src_slugs": skill_src["slugs"],
698
1412
  }
699
1413
 
1414
+ _SKILL_SRC_RE = re.compile(r"\.claude/skills/([a-z0-9][a-z0-9-]*)/")
1415
+ _FILE_PATH_RE = re.compile(r'"file_path"\s*:\s*"([^"]+)"')
1416
+
1417
+ def _scrape_skill_source_edits(self) -> dict:
1418
+ """当轮 Agent 有没有**改自己加载的 skill 源码**(`~/.claude/skills/<slug>/…`)。
1419
+
1420
+ #277 实证:跨境店跑 managing-promotions,Agent 连撞三个本土专有假设,
1421
+ 在 pod 里 `edit` 了 `/home/aiuser/.claude/skills/managing-promotions/script.py` 三次
1422
+ 才把 dry-run 跑通 —— 工具零失败、脚本 `✓ Script completed`、关键词全中,
1423
+ 三个维度**一个都没察觉**,直接判 pass。可 pod 是一次性的:下次会话拿到的还是发布版,
1424
+ 照样跑不通。**这种轮次的「pass」是拿改过的代码跑出来的,必须降级人工核。**
1425
+
1426
+ 判据取 edit/write 类工具**參數里的 `file_path`**(不扫结果体、不扫全文)——
1427
+ Agent 经常写一次性诊断脚本、内容里引用 `~/.claude/skills/…`,扫全文会满屏误报。
1428
+ 代价是:參數不暴露 `file_path` 的工具形态会漏检(宁漏勿误报,漏了还有 Wire 兜底)。
1429
+ 只回**计数 + skill slug**,不回參數原文(同结果体:可能含 env 明文密钥/JWT,gw#2350)。
1430
+ """
1431
+ try:
1432
+ blobs = self._eval(r"""()=>{
1433
+ const bs=[...document.querySelectorAll('button[aria-expanded]')].filter(e=>/個工具|个工具/.test(e.textContent||'')&&!e.hasAttribute('data-old'));
1434
+ // 🔴 **同 #676**:面板不在就回空,**不回退到整页** ——
1435
+ // 否则 Agent 回复里任何带 `參數/結果` 字样的东西都会被当成工具行。
1436
+ const last=bs[bs.length-1]; if(!last) return []; const panel=last.parentElement;
1437
+ const ops=[...panel.querySelectorAll('div')].filter(e=>e.querySelectorAll('button').length===2 && /參數|参数/.test(e.textContent) && /結果|结果/.test(e.textContent) && e.textContent.trim().length<12);
1438
+ const out=[];
1439
+ for(const op of ops){
1440
+ const row=op.parentElement;
1441
+ const t=(row?.innerText||'').replace(/\n/g,' ').trim();
1442
+ if((t.match(/參數|参数/g)||[]).length!==1) continue;
1443
+ const m=t.match(/^(\S+)\s+/);
1444
+ if(!m||!/^(edit|write|multi_edit|multiedit|notebook_edit|str_replace_editor)$/i.test(m[1])) continue;
1445
+ for(const b of op.querySelectorAll('button')){ if(/^(參數|参数)$/.test((b.textContent||'').trim())) b.click(); }
1446
+ out.push((row?.parentElement?.innerText)||'');
1447
+ }
1448
+ return out;
1449
+ }""") or []
1450
+ except Exception: # noqa: BLE001
1451
+ return {"count": 0, "slugs": []}
1452
+ self.page.wait_for_timeout(400)
1453
+ count, slugs = 0, set()
1454
+ for blob in blobs:
1455
+ for path in self._FILE_PATH_RE.findall(blob or ""):
1456
+ m = self._SKILL_SRC_RE.search(path)
1457
+ if m:
1458
+ count += 1
1459
+ slugs.add(m.group(1))
1460
+ return {"count": count, "slugs": sorted(slugs)}
1461
+
700
1462
  def read_tool_io(self, index: int) -> dict:
701
1463
  """深挖第 index 个 tool 的參數/結果内容(点开对应「參數」「結果」button 读文本)。判定存疑时用。"""
702
1464
  return self._eval(r"""(idx)=>{
@@ -713,9 +1475,36 @@ class ChatDriver:
713
1475
  """当轮 Agent 通过 `load_skill` 加载了哪些 skill —— 用于精确验证 must_call(「到底调没调对 skill」)。
714
1476
  实测:load_skill 的參數展开是 `<pre>` JSON `{"name": "<slug>"}`。
715
1477
  **结构无关**:不管有没有「N 個工具」聚合头——**单工具轮次**(只 load_skill、无 bash)不生成聚合头,
716
- load_skill 是个 `<span>` + 就近 `參數/結果` 按钮。直接找所有 `load_skill` 条目、点它的「參數」,读 pre。"""
717
- # 直接定位每个 load_skill 条目(span/div,自身文本恰为 load_skill),点开它就近的「參數」
718
- self._eval(r"""()=>{
1478
+ load_skill 是个 `<span>` + 就近 `參數/結果` 按钮。直接找所有 `load_skill` 条目、点它的「參數」,读 pre。
1479
+
1480
+ ⚠️ **两个都踩过的坑(2026-09-09 实测)**:
1481
+
1482
+ 1. **不能扫全页 `<pre>` 只匹配 `"name"`** —— Agent 最终回复里任何带 `name` 字段的 JSON 代码块
1483
+ (markdown ```json``` 渲染成 `<pre>`)都会被当成加载过的 skill,实测把 meta.json 的商品名
1484
+ `"Lampu Suluh LED Super Terang TJ-G089…"` 记成 skill ⇒ `verify_must_call` **假通过**。
1485
+ 2. **「點開參數」是 toggle,不是 open** —— `wait_reply` 里的 `_expand_all_tools()` 已经把面板
1486
+ 展开过了,这里再点一次就**把它关掉**,参数 `<pre>` 消失 ⇒ 返回 `[]` **假不通过**。
1487
+ (实测:点击前全页 3 个 `<pre>`,点击后 2 个。)
1488
+
1489
+ 修法:**靠形状判别,不靠容器/位置**。load_skill 的參數面板形状是固定的、**只有一个 key**:
1490
+ `{"name": "<slug>"}`;而误报源(listing.json / meta.json 代码块)必然还带别的 key。
1491
+ 并且先读一遍再决定要不要点——已经展开就别去 toggle 它。"""
1492
+ # 只认「整段恰好是 {"name": "<slug>"}」的 pre —— 商品/档案 JSON 必然还有 product_id 等其它键,天然排除
1493
+ READ = r"""()=>{
1494
+ const out=[];
1495
+ for(const pre of document.querySelectorAll('pre')){
1496
+ const m=(pre.textContent||'').trim().match(/^\{\s*"name"\s*:\s*"([^"]+)"\s*\}$/);
1497
+ if(m) out.push(m[1]);
1498
+ }
1499
+ return [...new Set(out)];
1500
+ }"""
1501
+ n_calls = self._eval(r"""()=>[...document.querySelectorAll('span,div')]
1502
+ .filter(e=>(e.textContent||'').trim()==='load_skill' && e.getBoundingClientRect().width>0).length""")
1503
+ got = self._eval(READ) or []
1504
+ if not n_calls or len(got) >= n_calls:
1505
+ return got # 面板已经是展开的(或本轮压根没调 load_skill)—— 别去 toggle 它
1506
+ # 还没展开:点一次「參數」再读
1507
+ CLICK = r"""()=>{
719
1508
  const nodes=[...document.querySelectorAll('span,div')].filter(e=>(e.textContent||'').trim()==='load_skill' && e.getBoundingClientRect().width>0);
720
1509
  for(const n of nodes){
721
1510
  let box=n; for(let i=0;i<5 && box;i++){ box=box.parentElement;
@@ -724,17 +1513,14 @@ class ChatDriver:
724
1513
  if(p){ p.click(); break; }
725
1514
  }
726
1515
  }
727
- }""")
728
- self.page.wait_for_timeout(900)
729
- # 读全页 <pre> 里的 {"name":"<slug>"}(当轮对话已 new_conversation 隔离,只此一轮)
730
- return self._eval(r"""()=>{
731
- const out=[];
732
- for(const pre of document.querySelectorAll('pre')){
733
- const m=(pre.textContent||'').match(/"name"\s*:\s*"([^"]+)"/);
734
- if(m) out.push(m[1]);
735
- }
736
- return [...new Set(out)];
737
- }""") or []
1516
+ }"""
1517
+ for _ in range(2): # 第 2 次是兜底:万一第 1 次点反了(把已开的关上),再点回来
1518
+ self._eval(CLICK)
1519
+ self.page.wait_for_timeout(900)
1520
+ got = self._eval(READ) or []
1521
+ if len(got) >= n_calls:
1522
+ break
1523
+ return got
738
1524
 
739
1525
  def verify_must_call(self, expected) -> dict:
740
1526
  """验证当轮是否加载了期望 skill。expected 可为单个 slug 或 slug 列表(**多选之一**——
@@ -769,12 +1555,24 @@ class ChatDriver:
769
1555
 
770
1556
  def card_status(self, slug: str) -> str:
771
1557
  """搜到卡后读该 slug 卡的状态:已安裝 / 待安裝 / 安裝中 / notfound。
772
- ⚠️ UI 文案随账号 locale 简繁都可能出现(zh-HK「安裝」/ zh-CN「安装」),两种都要匹配。"""
1558
+ ⚠️ UI 文案随账号 locale 简繁都可能出现(zh-HK「安裝」/ zh-CN「安装」),两种都要匹配。
1559
+ ⚠️ **必须遍历 slug 的全部出现位置,不能只取 indexOf 的首次命中**——skill 描述里会互相引用
1560
+ (`developing-video-creative` 的描述写着「交给 producing-hq-ugc-video 生产」,
1561
+ `generating-listing-video` 的描述写着「发布交 publishing-shoppable-video」),
1562
+ 首次命中往往落在**别人卡片的描述文字**上,其后 60 字内没有状态词 → 误报 '?' → 上层记「失败」,
1563
+ 明明装着却被判没装(2026-09-09 实测踩中)。真卡片的 slug 后面紧跟「免費/包含 N 個技能/已安裝」。"""
773
1564
  return self._eval(r"""(slug)=>{
774
- const body=document.body.innerText||''; const i=body.indexOf(slug);
775
- if(i<0) return 'notfound';
776
- const seg=body.slice(i, i+60);
777
- return /已安[裝装]/.test(seg)?'已安裝':/安[裝装]中/.test(seg)?'安裝中':/安[裝装]/.test(seg)?'待安裝':'?';
1565
+ const body=document.body.innerText||'';
1566
+ if(body.indexOf(slug)<0) return 'notfound';
1567
+ let seen=false;
1568
+ for(let i=body.indexOf(slug); i>=0; i=body.indexOf(slug, i+1)){
1569
+ seen=true;
1570
+ const seg=body.slice(i, i+60);
1571
+ if(/已安[裝装]/.test(seg)) return '已安裝';
1572
+ if(/安[裝装]中/.test(seg)) return '安裝中';
1573
+ if(/安[裝装]/.test(seg)) return '待安裝';
1574
+ }
1575
+ return seen?'?':'notfound';
778
1576
  }""", slug)
779
1577
 
780
1578
  def search_skill(self, slug: str) -> str:
@@ -823,7 +1621,7 @@ class ChatDriver:
823
1621
  if __name__ == "__main__":
824
1622
  # 冒烟:attach + 报告当前页 + 已装技能数
825
1623
  import sys
826
- d = ChatDriver().attach()
1624
+ d = ChatDriver().attach(ziniao=None, reason="冒烟只看 URL 和已装技能数,不驱动任何店")
827
1625
  print("URL:", d.page.url)
828
1626
  print("在 chat 页:", "/chat" in d.page.url)
829
1627
  if "--install" in sys.argv: