tianshu-mcp 0.6.1 → 0.6.2

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.
package/CHANGELOG.en.md CHANGED
@@ -8,6 +8,25 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
8
8
 
9
9
  ---
10
10
 
11
+ ## [0.6.2] - 2026-09-23
12
+
13
+ ### Fixed
14
+
15
+ - **Codex project-picker trigger copy drifts across versions** ([issue #23](https://github.com/lanlan0811/tianshu-mcp/issues/23), case 1): the trigger's `aria-label` is "选择项目:<name>" on some versions and "切换项目:<name>" on others (26.915, measured locally). `projectPickerTrigger` now covers both copies via primary/fallback/pattern (including the English `Select/Switch project`), and `boundProjectName` reads both back. **A full 20-key hardware audit was run** (`node scripts/probe-codex.mjs --launch audit`): on local 26.915 there is no drift beyond this trigger, and 9 verified keys were bumped to `verifiedVersion: 26.915.x`.
16
+ - **Qoder workspace binding failed on 0.3.4** (issue #23, case 2): a fresh hardware probe **corrected the issue's conclusion** — the 0.3.4 workspace menu **does render**; the real cause is that the page has **two** `[data-workspace-picker-trigger]` elements, and the old single-match `click()` failed as ambiguous. The workspace trigger's primary selector is now the unique `button[aria-label^="切换或清空当前工作区"]` (with `[data-workspace-picker-trigger]` demoted to a fallback); the "menu is open" check is relaxed to "search box **or** overlay". The production `bindWorkspace` now passes on real Qoder 0.3.4.
17
+ - **TraeWork install discovery failed** (issue #23, case 3): added `src/agents/traework/discovery.ts` (fixed-drive enumeration + registry `InstallLocation` + relative paths) with a dedicated `traework-gui` branch in `registry.ts`. Fixed the built-in profile — removed the wrong `{APPDATA}/TRAE SOLO CN` (measured to be the **user-data dir**: Cache/Crashpad/nested tool exes, not the install location) in favor of `{LOCALAPPDATA}/Programs/TRAE SOLO CN` etc., and added `preferredDrives`/`relativePaths`. **On Windows the executable name is narrowed to `TRAE SOLO CN.exe`** (the old list's `Trae CN` mismatched the unrelated TraeCode CN product). `waitReady` timeouts now emit on-site diagnostics (child exit code, port-listener enumeration, detection of existing instances without the debug port) — **diagnosis only: no launch-strategy change, no termination of existing instances**.
18
+
19
+ ### Added
20
+
21
+ - **Unified selector diagnostics across the three GUI agents** (`src/agents/gui-diagnostics.ts`): on selector-resolution failure the nearest visible page candidates (aria-labels / short texts) are appended to the error and log, so drift can be located in one step without opening CDP by hand. Wired into codex / qoder / traework.
22
+ - **Codex full-key audit mode** `scripts/probe-codex.mjs --launch audit`: prints the primary hit count and matched labels for all 20 selector keys as a "script + evidence table" gate.
23
+ - **Qoder layered selector structure**: `src/agents/qoder/selectors.ts` upgraded from flat strings to `primary/fallbacks/texts/ariaLabels/ariaPatterns/verifiedVersion` (27 keys); `QoderCdpClient.selector()` keeps its string semantics and adds `candidates()/existsKey()/clickKey()`.
24
+ - **TraeWork selector version field unified**: `verified: boolean` → `verifiedVersion: string` (matching codex/kimicode).
25
+
26
+ ### Docs
27
+
28
+ - New [issue #23 verification record](docs/issue-23-selector-drift-record.md) (20-key audit table, Qoder 0.3.4 re-probe, TraeWork discovery hardware results); release notes [v0.6.2](docs/release-v0.6.2.en.md).
29
+
11
30
  ## [0.6.1] - 2026-09-23
12
31
 
13
32
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.6.2] - 2026-09-23
11
+
12
+ ### 修复
13
+
14
+ - **Codex 项目选择触发器文案跨版本漂移**([issue #23](https://github.com/lanlan0811/tianshu-mcp/issues/23) 例 1):触发器 `aria-label` 在部分版本为「选择项目:<名>」、部分(本机实测 26.915)仍为「切换项目:<名>」。`projectPickerTrigger` 主/回退/模板并列覆盖两文案(含英文 `Select/Switch project`),`boundProjectName` 的项目名回读同步兼容。**并完成全量 20 键真机审计**(`node scripts/probe-codex.mjs --launch audit`):本机 26.915 除该触发器外无其他漂移,9 个已实测键的 `verifiedVersion` 提升为 `26.915.x`。
15
+ - **Qoder 工作区绑定在 0.3.4 失败**(issue #23 例 2):真机重探**更正了 issue 的结论**——0.3.4 工作区菜单**并非不渲染**,真因是页面存在**两个** `[data-workspace-picker-trigger]`,旧 `click()` 要求唯一命中而判歧义失败。工作区触发器主选择器改用唯一的 `button[aria-label^="切换或清空当前工作区"]`(`[data-workspace-picker-trigger]` 降为回退);「菜单已打开」判定放宽为「搜索框 **或** 浮层」。生产 `bindWorkspace` 已在真实 Qoder 0.3.4 上跑通。
16
+ - **TraeWork 安装发现失败**(issue #23 例 3):新增 `src/agents/traework/discovery.ts`(固定盘枚举 + 注册表 `InstallLocation` + 相对路径),`registry.ts` 增 `traework-gui` 专用分支。修正内置 profile——删除错误的 `{APPDATA}/TRAE SOLO CN`(实测该目录是**用户数据目录**:Cache/Crashpad/嵌套工具 exe,非安装位置),改为 `{LOCALAPPDATA}/Programs/TRAE SOLO CN` 等,并补 `preferredDrives`/`relativePaths`。**Windows 文件名收窄为只认 `TRAE SOLO CN.exe`**(旧清单含 `Trae CN`,会误匹配另一产品 TraeCode CN)。`waitReady` 超时新增现场诊断(子进程退出码、端口监听者枚举、既有未带调试端口实例检测),**只诊断、不改启动策略、不终止既有实例**。
17
+
18
+ ### 新增
19
+
20
+ - **三 GUI agent 统一的选择器诊断**(`src/agents/gui-diagnostics.ts`):选择器解析失败时把页面可见候选标签(最接近的 aria-label / 短文本)写进错误与日志,使用者一步定位漂移,无需人工开 CDP。已接入 codex / qoder / traework。
21
+ - **Codex 全键审计模式** `scripts/probe-codex.mjs --launch audit`:对全部 20 个语义键输出 primary 命中数与命中标签,作为「脚本 + 证据表」门禁。
22
+ - **Qoder 选择器分层结构**:`src/agents/qoder/selectors.ts` 由扁平字符串升级为 `primary/fallbacks/texts/ariaLabels/ariaPatterns/verifiedVersion`(27 键);`QoderCdpClient.selector()` 保留字符串语义,另增 `candidates()/existsKey()/clickKey()`。
23
+ - **TraeWork 选择器版本字段统一**:`verified: boolean` → `verifiedVersion: string`(与 codex/kimicode 一致)。
24
+
25
+ ### 文档
26
+
27
+ - 新增 [issue #23 验证记录](docs/issue-23-selector-drift-record.md)(含 20 键审计表、Qoder 0.3.4 重探、TraeWork discovery 真机结果);发布说明 [v0.6.2](docs/release-v0.6.2.md)。
28
+
10
29
  ## [0.6.1] - 2026-09-23
11
30
 
12
31
  ### 新增
package/README.en.md CHANGED
@@ -457,6 +457,12 @@ On stderr, `INFO` means skipped/installed; `WARN` means a stale copy was upgrade
457
457
  - **`cmd` string form documented**: the bilingual docs and the schema/source comments state "prefer the array form; the string form **supports no escaping**, an unclosed quote does not error, and a typo silently splits into multiple argv". Five boundary cases pin the existing tokenizer semantics (no behaviour change)
458
458
  - **Counts and scenario numbers aligned**: the "9 tools" header comment in `protocol.test.ts` is corrected to 11 and a `TOOL_DEFS` count assertion is added; the "6 scenarios" references in `ci.yml` / `HANDOFF` / `CONTRIBUTING` (both languages) are synced to 8 (they lagged behind the two skill scenarios added in issue #16)
459
459
  - See the [v0.6.1 release notes](<docs/release-v0.6.1.en.md>); verification record in the [issue-17 record](docs/issue-17-small-fixes-record.md)
460
+ - **M32 — GUI selector version-drift fixes (codex / qoder / traework) + v0.6.2** (2026-09-23) — 26 new cases (issue #23)
461
+ - **Codex**: the project-picker trigger copy drifts across versions (26.915 locally measured "切换项目", 26.917 "选择项目"); primary+fallback now cover both, and `boundProjectName` reads both back. A new `probe-codex.mjs audit` prints a hit table for all 20 keys — **on local 26.915 there is no drift other than this trigger**
462
+ - **Qoder**: selectors upgraded from flat strings to a layered structure matching Codex (27 keys with `verifiedVersion`). **The hardware re-probe corrected the issue's conclusion**: on 0.3.4 the workspace menu **does render** — the real cause was **two** `[data-workspace-picker-trigger]` elements breaking the single-match click. Switching to the unique `button[aria-label^="切换或清空当前工作区"]` makes the production `bindWorkspace` pass on real 0.3.4
463
+ - **TraeWork**: added `discovery.ts` (fixed-drive enumeration + registry + relative paths) and fixed the built-in dir (`{APPDATA}/TRAE SOLO CN` is actually the **user-data dir**, not the install location). On Windows the executable name is narrowed to `TRAE SOLO CN.exe` (the old list's `Trae CN` mismatched the unrelated TraeCode CN product); a port-not-ready path now prints diagnostics (exit code / port listeners / existing instances) — **diagnosis only, no launch-strategy change**
464
+ - **Unified diagnostics**: new `src/agents/gui-diagnostics.ts` appends the nearest visible page candidates to selector-resolution failures across all three GUI agents
465
+ - See the [v0.6.2 release notes](<docs/release-v0.6.2.en.md>); verification record in the [issue-23 record](docs/issue-23-selector-drift-record.md)
460
466
 
461
467
  ## Agent support status
462
468
 
package/README.md CHANGED
@@ -458,6 +458,12 @@ run_task(projectPath=D:/xxx/my-app, agentId=qoder, planDoc=./plans/development.m
458
458
  - **`cmd` 字符串形态文档化**:双语文档与 schema/代码注释明示「推荐数组形态;字符串形态**不支持转义**、引号不闭合不报错、写错会静默拆成多个 argv」;补 5 条边界用例锁定既有分词语义(零行为变更)
459
459
  - **计数与场景数对齐**:`protocol.test.ts` 头注释「9 个工具」更正为 11 并新增 `TOOL_DEFS` 数量硬断言;`ci.yml` / `HANDOFF` / `CONTRIBUTING` 双语的「6 场景」同步为 8(issue #16 新增两技能场景后未同步)
460
460
  - 详见 [v0.6.1 发布说明](<docs/release-v0.6.1.md>);验证记录见 [issue-17 记录](docs/issue-17-small-fixes-record.md)
461
+ - **M32 — GUI 选择器版本漂移修复(codex / qoder / traework 三例)+ v0.6.2**(2026-09-23)— 新增 26 项用例(issue #23)
462
+ - **Codex**:项目选择触发器文案跨版本漂移(26.915 本机实测为「切换项目」,26.917 为「选择项目」),主/回退并列覆盖两文案;`boundProjectName` 回读同步兼容;新增 `probe-codex.mjs audit` 对全部 20 键输出命中表,**本机 26.915 除该触发器外无其他漂移**
463
+ - **Qoder**:选择器由扁平字符串升级为与 Codex 同构的分层结构(27 键,含 `verifiedVersion`);**真机重探更正 issue 结论**——0.3.4 工作区菜单**并非不渲染**,真因是页面有**两个** `[data-workspace-picker-trigger]` 致唯一点击判歧义失败;改用唯一的 `button[aria-label^="切换或清空当前工作区"]` 后,生产 `bindWorkspace` 已在真实 0.3.4 跑通
464
+ - **TraeWork**:新增 `discovery.ts`(固定盘枚举 + 注册表 + 相对路径),修正内置目录(`{APPDATA}/TRAE SOLO CN` 实为**用户数据目录**,非安装位置);Windows 文件名收窄为只认 `TRAE SOLO CN.exe`(旧清单含 `Trae CN` 会误匹配另一产品 TraeCode CN);端口未就绪时输出诊断(退出码 / 端口监听者 / 既有实例),**只诊断不改启动策略**
465
+ - **统一诊断**:新增 `src/agents/gui-diagnostics.ts`,三 GUI agent 在选择器解析失败时把「页面可见候选」写进错误,使用者一步定位漂移
466
+ - 详见 [v0.6.2 发布说明](<docs/release-v0.6.2.md>);验证记录见 [issue-23 记录](docs/issue-23-selector-drift-record.md)
461
467
 
462
468
  ## Agent 适配现状
463
469
 
@@ -135,20 +135,21 @@ export const BUILTIN_PROFILES = {
135
135
  killTree: "taskkill",
136
136
  authNote: "复用 TraeWork 桌面端登录态;窗口需保持可见(发送依赖模拟输入)",
137
137
  executableDiscovery: {
138
+ // issue #23 C3:原先写成 {APPDATA}/TRAE SOLO CN(Roaming),实际安装在
139
+ // {LOCALAPPDATA}/Programs(Local)。补 preferredDrives + relativePaths + 注册表探测
140
+ // (由 traework/discovery.ts 消费),Windows 只认 TRAE SOLO CN.exe。
138
141
  dirs: [
139
- "D:/TRAE Work CN",
140
- "{PROGRAMFILES}/TRAE WORK CN",
141
- "{PROGRAMFILES(X86)}/TRAE WORK CN",
142
- "{LOCALAPPDATA}/Programs/TRAE WORK CN",
143
- "{LOCALAPPDATA}/TRAE WORK CN",
144
- "{APPDATA}/TRAE SOLO CN",
142
+ "{LOCALAPPDATA}/Programs/TRAE SOLO CN",
143
+ "{LOCALAPPDATA}/TRAE SOLO CN",
144
+ "{PROGRAMFILES}/TRAE SOLO CN",
145
+ "{PROGRAMFILES(X86)}/TRAE SOLO CN",
145
146
  "/Applications/TraeWork.app/Contents/MacOS",
146
147
  "/Applications/Trae CN.app/Contents/MacOS",
147
148
  ],
148
- fileNames: ["TRAE SOLO CN.exe", "TraeWork", "TraeWork CN", "Trae CN"],
149
+ fileNames: process.platform === "darwin" ? ["TRAE SOLO CN", "TraeWork", "Trae CN"] : ["TRAE SOLO CN.exe"],
149
150
  fallbackCommand: undefined,
150
- preferredDrives: [],
151
- relativePaths: [],
151
+ preferredDrives: ["D:"],
152
+ relativePaths: ["TRAE Work CN/TRAE SOLO CN.exe"],
152
153
  installRelativeExe: [],
153
154
  scanRoots: [],
154
155
  },
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { TraeworkCdpClient, CdpDisconnectedError, CdpUnavailableError } from "../traework/cdp/client.js";
8
8
  import { resolveFnSource, specArgs } from "./selectors.js";
9
+ import { visibleLabelsExpr, normalizeLabels } from "../gui-diagnostics.js";
9
10
  export { CdpDisconnectedError, CdpUnavailableError };
10
11
  export class CodexCdpClient {
11
12
  selectors;
@@ -81,6 +82,20 @@ export class CodexCdpClient {
81
82
  async exists(key) {
82
83
  return this.evaluate(this.withResolve(`${this.visibleFilter}const els=__codexResolve(${specArgs(key, this.selectors)});return els.some(vis);`));
83
84
  }
85
+ /**
86
+ * 收集页面可见候选标签(issue #23 诊断机制):选择器解析失败时,
87
+ * 把「页面实际有什么」写进错误信息,使用者一步定位漂移(无需人工开 CDP)。
88
+ * 永不抛错——诊断失败返回空数组,不影响主流程。
89
+ */
90
+ async visibleLabels(scopeCss) {
91
+ try {
92
+ const raw = await this.evaluate(visibleLabelsExpr({ scope: scopeCss }));
93
+ return normalizeLabels(raw);
94
+ }
95
+ catch {
96
+ return [];
97
+ }
98
+ }
84
99
  /** 第一个可见元素文本 */
85
100
  async text(key) {
86
101
  return ((await this.evaluate(this.withResolve(`${this.visibleFilter}const els=__codexResolve(${specArgs(key, this.selectors)});for(const e of els){if(vis(e))return ((e.value!==undefined?e.value:'')||e.textContent||'').trim()}return '';`))) || "");
@@ -293,7 +308,8 @@ export class CodexCdpClient {
293
308
  }
294
309
  /**
295
310
  * 读取当前会话已绑定的项目名。
296
- * 权威信号:输入框内的项目 chip 带 `aria-label="切换项目:<名>"`(未绑定时为「不在项目中工作」)。
311
+ * 权威信号:输入框内的项目 chip 带 `aria-label="选择项目:<名>"`(26.917 起;
312
+ * 26.903 及更早为「切换项目:<名>」;未绑定时为「不在项目中工作」)。
297
313
  * 真机教训:早期实现把所有非 chip 按钮文本拼起来,会返回整页文案并意外“通过”匹配。
298
314
  */
299
315
  async boundProjectName() {
@@ -303,7 +319,7 @@ export class CodexCdpClient {
303
319
  for(const e of document.querySelectorAll('[aria-label]')){
304
320
  if(!vis(e))continue;
305
321
  const aria=norm(e.getAttribute('aria-label'));
306
- const m=/^(?:切换项目|Switch project)[::]\\s*(.+)$/i.exec(aria);
322
+ const m=/^(?:选择项目|切换项目|Select project|Switch project)[::]\\s*(.+)$/i.exec(aria);
307
323
  if(m&&m[1])return m[1].trim();
308
324
  }
309
325
  return '';
@@ -26,6 +26,7 @@ import { focusCodexApp } from "./launcher.js";
26
26
  import { ensureProjectRegistered } from "./registry.js";
27
27
  import { buildInitialPrompt } from "./input.js";
28
28
  import { validateTaskReferences } from "../zcode/references.js";
29
+ import { withDiagnostics } from "../gui-diagnostics.js";
29
30
  const DEFAULT_DEPS = {
30
31
  discover: discoverCodex,
31
32
  ensureInstance: ensureCodexInstance,
@@ -496,15 +497,24 @@ async function stopGuiTurn(cdp, gui, deps, logger) {
496
497
  */
497
498
  async function createProject(cdp, projectPath, aumid, gui, deps, logger) {
498
499
  // 新建会话后输入框会重渲染,触发器可能短暂缺席 —— 先等它出现再点。
499
- if (!(await waitFor(cdp, "projectPickerTrigger", deps, 12_000)))
500
+ if (!(await waitFor(cdp, "projectPickerTrigger", deps, 12_000))) {
501
+ // issue #23 诊断机制:解析失败时把页面可见候选一并给出,使用者一步定位文案漂移。
502
+ const labels = await cdp.visibleLabels();
500
503
  return {
501
504
  ok: false,
502
- error: "新建会话后未出现项目选择触发器(可用 gui.selectors.projectPickerTrigger 热修复)",
505
+ error: withDiagnostics("新建会话后未出现项目选择触发器(可用 gui.selectors.projectPickerTrigger 热修复)", labels),
503
506
  };
507
+ }
504
508
  if (!(await cdp.click("projectPickerTrigger")))
505
- return { ok: false, error: "项目选择触发器点击失败" };
509
+ return {
510
+ ok: false,
511
+ error: withDiagnostics("项目选择触发器点击失败", await cdp.visibleLabels()),
512
+ };
506
513
  if (!(await waitFor(cdp, "newProjectMenuItem", deps, 8_000)))
507
- return { ok: false, error: "项目选择弹层未出现(找不到「新建项目」项)" };
514
+ return {
515
+ ok: false,
516
+ error: withDiagnostics("项目选择弹层未出现(找不到「新建项目」项)", await cdp.visibleLabels()),
517
+ };
508
518
  const menu = await cdp.clickExact("newProjectMenuItem", "新建项目");
509
519
  if (!menu.clicked)
510
520
  return {
@@ -13,14 +13,14 @@ export const CODEX_SELECTORS = {
13
13
  primary: 'div.ProseMirror[contenteditable="true"]',
14
14
  fallbacks: ['[contenteditable="true"][role="textbox"]', 'textarea[placeholder]'],
15
15
  ariaLabels: ["随心输入", "Type a message", "Message", "输入消息"],
16
- verifiedVersion: "26.903.x",
16
+ verifiedVersion: "26.915.x",
17
17
  note: "ProseMirror 富文本输入框;必须走 CDP 输入,不能设 value",
18
18
  },
19
19
  sendButton: {
20
20
  primary: 'button[aria-label="发送"]',
21
21
  fallbacks: ['button[aria-label*="Send" i]', 'button[type="submit"]'],
22
22
  ariaLabels: ["发送", "Send"],
23
- verifiedVersion: "26.903.x",
23
+ verifiedVersion: "26.915.x",
24
24
  note: "条件渲染:输入框为空时不存在,出现即代表可发送",
25
25
  },
26
26
  stopButton: {
@@ -49,40 +49,52 @@ export const CODEX_SELECTORS = {
49
49
  primary: 'button.sidebar-item',
50
50
  fallbacks: ['nav button', 'aside button'],
51
51
  texts: ["新对话", "New chat"],
52
- verifiedVersion: "26.903.x",
52
+ verifiedVersion: "26.915.x",
53
53
  note: "侧边栏新建会话",
54
54
  },
55
55
  projectSection: {
56
56
  primary: 'button[class*="section-toggle"]',
57
57
  fallbacks: ['nav button', 'aside button'],
58
58
  texts: ["项目", "Projects"],
59
- verifiedVersion: "26.903.x",
59
+ verifiedVersion: "26.915.x",
60
60
  note: "侧边栏项目分组开关",
61
61
  },
62
62
  addProject: {
63
63
  primary: 'button[aria-label="添加新项目"]',
64
64
  fallbacks: ["button[aria-label*='添加' i]", "button[aria-label*='Add project' i]"],
65
65
  ariaLabels: ["添加新项目", "Add new project", "Add project"],
66
- verifiedVersion: "26.903.x",
66
+ verifiedVersion: "26.915.x",
67
67
  note: "打开新建项目入口",
68
68
  },
69
69
  projectPickerTrigger: {
70
- // 真机实测:触发器 = aria-label「切换项目:<名>」(aria-haspopup="dialog")。
71
- // 注意两点:
70
+ // issue #23:触发器 aria-label 存在版本间文案漂移。
71
+ // - 26.915(本机实测):仍为「切换项目:<名>」
72
+ // - 26.917(issue #23 报告):已漂移为「选择项目:<名>」
73
+ // 两者均为 aria-haspopup="dialog",故主/回退并列覆盖,兼容 26.903 及更早的「切换项目」。
74
+ // 注意两点(沿用旧结论,不得回归):
72
75
  // 1) 它**不在** ComposerLayout 作用域内;
73
76
  // 2) 侧边栏有 aria-label="添加新项目"、输入框另有独立按钮 aria-label="不在项目中工作"
74
77
  // (那是「离开项目」动作,不是触发器)——都不能混入本键,否则会误点。
75
- primary: 'button[aria-haspopup="dialog"][aria-label^="切换项目"]',
76
- fallbacks: ["button[aria-haspopup='dialog'][aria-label^='Switch project']"],
77
- ariaPatterns: ["^切换项目[::]", "^Switch project[::]"],
78
- verifiedVersion: "26.903.x",
79
- note: "输入框内的项目选择触发器(已绑定/未绑定均为「切换项目…」语义)",
78
+ primary: 'button[aria-haspopup="dialog"][aria-label^="选择项目"]',
79
+ fallbacks: [
80
+ 'button[aria-haspopup="dialog"][aria-label^="切换项目"]',
81
+ "button[aria-haspopup='dialog'][aria-label^='Select project']",
82
+ "button[aria-haspopup='dialog'][aria-label^='Switch project']",
83
+ ],
84
+ ariaPatterns: [
85
+ "^选择项目[::]",
86
+ "^切换项目[::]",
87
+ "^Select project[::]",
88
+ "^Switch project[::]",
89
+ ],
90
+ verifiedVersion: "26.915–26.917",
91
+ note: "输入框内的项目选择触发器;26.915 实测文案为「切换项目」,issue #23 报告 26.917 为「选择项目」,两者并列命中(含 26.903 及更早)",
80
92
  },
81
93
  projectItem: {
82
94
  primary: 'button[aria-label$="的项目操作"]',
83
95
  fallbacks: ["button[aria-label*='project actions' i]"],
84
96
  ariaPatterns: ["的项目操作$", "project actions$"],
85
- verifiedVersion: "26.903.x",
97
+ verifiedVersion: "26.915.x",
86
98
  note: "项目项(按 aria-label 前缀提取项目名)",
87
99
  },
88
100
  newProjectMenuItem: {
@@ -144,7 +156,7 @@ export const CODEX_SELECTORS = {
144
156
  // 作用域:输入框容器(CSS Module 基名 ComposerLayout 稳定,哈希后缀会变)。
145
157
  // 兼容性优先,同时列出 ProseMirror 元素本身。
146
158
  scope: '[class*="ComposerLayout"],div.ProseMirror[contenteditable="true"]',
147
- verifiedVersion: "26.903.x",
159
+ verifiedVersion: "26.915.x",
148
160
  note: "模型+思考等级菜单触发器;限定在输入框作用域内,排除顶部菜单栏与模式切换器",
149
161
  },
150
162
  reasoningSlider: {
@@ -178,7 +190,7 @@ export const CODEX_SELECTORS = {
178
190
  primary: 'button[aria-label="更改权限"]',
179
191
  fallbacks: ["button[aria-label*='权限' i]", "button[aria-label*='permission' i]"],
180
192
  ariaLabels: ["更改权限", "Change permission", "Permissions"],
181
- verifiedVersion: "26.903.x",
193
+ verifiedVersion: "26.915.x",
182
194
  note: "权限模式触发器(文本如「完全访问」)",
183
195
  },
184
196
  permissionOption: {
@@ -205,7 +217,7 @@ export const CODEX_SELECTORS = {
205
217
  '[class*="MessageList" i]',
206
218
  '[class*="message-list" i]',
207
219
  ],
208
- verifiedVersion: "26.903.x",
220
+ verifiedVersion: "26.915.x",
209
221
  note: "对话正文区域;用于文本稳定兜底判定(勿用裸 main/#root,会混入导航壳)",
210
222
  },
211
223
  };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * GUI 选择器解析失败的统一诊断(issue #23「机制」建议)。
3
+ *
4
+ * 三个 GUI agent(codex / qoder / traework)的选择器解析失败信息往往只有
5
+ * 「未出现 X」——但报告者真正需要的是「页面上实际有什么」。issue #23 的调查里,
6
+ * 最有价值的线索(`aria-haspopup="dialog"` 恰 1 个、aria-label 已变成「选择项目」)
7
+ * 是人工打开 CDP 探针才拿到的。本模块把这步自动化:解析失败时把页面可见候选
8
+ * 标签一并写进错误信息与日志。
9
+ *
10
+ * 约定:本模块只产出「页面内表达式」与「文本」,不参与任何控制流或判定,
11
+ * 因此注入失败、页面无元素等情况都必须安全降级为空候选。
12
+ */
13
+ export const DEFAULT_DIAGNOSTIC_LIMIT = 20;
14
+ /** 交互元素扫描选择器(与 codex resolve 的 scan 面保持一致,宁可多扫后截断) */
15
+ const DIAGNOSTIC_SCAN = '[aria-label],[role="button"],[role="menuitem"],[role="menuitemradio"],[role="option"],[role="tab"],button,a,label';
16
+ /**
17
+ * 生成页面内表达式:收集当前可见、带 aria-label 或可见文本的交互元素标签
18
+ * (归一化、去重、截断)。返回 `string[]`,可直接交给 `cdp.evaluate`。
19
+ */
20
+ export function visibleLabelsExpr(opts = {}) {
21
+ const limit = Math.max(1, Math.floor(opts.limit ?? DEFAULT_DIAGNOSTIC_LIMIT));
22
+ const scopeSel = opts.scope ? JSON.stringify(opts.scope) : "''";
23
+ const includeText = opts.includeText !== false;
24
+ const scan = JSON.stringify(DIAGNOSTIC_SCAN);
25
+ return `(function(){
26
+ try{
27
+ var limit=${limit},includeText=${includeText},scopeSel=${scopeSel},scan=${scan};
28
+ var root=scopeSel?document.querySelector(scopeSel):document;
29
+ if(!root)return [];
30
+ var vis=function(e){try{var r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth}catch(_){return false}};
31
+ var norm=function(s){return (s||'').normalize('NFC').replace(/\\s+/g,' ').trim()};
32
+ var out=[],seen={};
33
+ var push=function(s){var v=norm(s);if(!v||v.length>80)return;var k=v.toLocaleLowerCase();if(seen[k])return;seen[k]=1;out.push(v)};
34
+ var nodes=root.querySelectorAll(scan);
35
+ for(var i=0;i<nodes.length;i++){
36
+ if(out.length>=limit)break;
37
+ var e=nodes[i];
38
+ if(!vis(e))continue;
39
+ var aria=norm(e.getAttribute('aria-label')||'');
40
+ if(aria){push(aria);continue}
41
+ if(includeText){var t=norm(e.innerText||e.textContent||'');if(t&&t.length<=40)push(t)}
42
+ }
43
+ return out;
44
+ }catch(_){return []}
45
+ })()`;
46
+ }
47
+ /** 从 CDP 返回值安全提取字符串标签数组(过滤非字符串、去重、截断)。 */
48
+ export function normalizeLabels(raw, max = DEFAULT_DIAGNOSTIC_LIMIT) {
49
+ if (!Array.isArray(raw))
50
+ return [];
51
+ const out = [];
52
+ const seen = new Set();
53
+ for (const item of raw) {
54
+ if (typeof item !== "string")
55
+ continue;
56
+ const v = item.replace(/\s+/g, " ").trim();
57
+ if (!v)
58
+ continue;
59
+ const key = v.toLocaleLowerCase();
60
+ if (seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ out.push(v);
64
+ if (out.length >= max)
65
+ break;
66
+ }
67
+ return out;
68
+ }
69
+ /** 把候选标签渲染成错误信息后缀(无候选返回空串)。 */
70
+ export function formatCandidates(labels, max = DEFAULT_DIAGNOSTIC_LIMIT) {
71
+ const list = normalizeLabels(labels, max);
72
+ if (!list.length)
73
+ return "";
74
+ return `;页面可见候选=[${list.join(" | ")}]`;
75
+ }
76
+ /** 幂等追加诊断:已含候选段时原样返回,避免重复拼接。 */
77
+ export function withDiagnostics(message, labels, max = DEFAULT_DIAGNOSTIC_LIMIT) {
78
+ if (/页面可见候选=/.test(message))
79
+ return message;
80
+ return message + formatCandidates(labels, max);
81
+ }
@@ -1,5 +1,6 @@
1
1
  import { TraeworkCdpClient } from '../traework/cdp/client.js';
2
- import { QODER_SELECTORS } from './selectors.js';
2
+ import { QODER_SELECTORS, qoderCandidates, qoderPrimary } from './selectors.js';
3
+ import { visibleLabelsExpr, normalizeLabels } from '../gui-diagnostics.js';
3
4
  import { setTimeout as delay } from 'node:timers/promises';
4
5
  const VISIBLE = `(e)=>{
5
6
  if(!e||e.closest('[inert],[hidden],[aria-hidden="true"],[role="dialog"][data-state="closed"]'))return false;
@@ -20,7 +21,9 @@ export class QoderCdpClient {
20
21
  this.overrides = overrides;
21
22
  this.client = new TraeworkCdpClient({ port, sendTimeoutMs: timeout, targetRank: qoderTargetRank });
22
23
  }
23
- selector(key) { return this.overrides[key] ?? QODER_SELECTORS[key]; }
24
+ selector(key) { return qoderPrimary(key, this.overrides); }
25
+ /** 该语义键的全部候选选择器(覆盖优先,其后 primary 与 fallbacks,去重保序) */
26
+ candidates(key) { return qoderCandidates(key, this.overrides); }
24
27
  async connect() {
25
28
  await this.client.connect();
26
29
  if (qoderTargetRank({ url: await this.evaluate('location.href') }) !== 0) {
@@ -33,8 +36,39 @@ export class QoderCdpClient {
33
36
  async sessionId() {
34
37
  return this.evaluate(`location.hash.startsWith('#/chat/')?location.hash.split('?')[0].slice(7):undefined`);
35
38
  }
39
+ /** 收集页面可见候选标签(issue #23 诊断机制);失败安全返回空数组。 */
40
+ async visibleLabels(scopeCss) {
41
+ try {
42
+ return normalizeLabels(await this.evaluate(visibleLabelsExpr({ scope: scopeCss })));
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ /** 按候选顺序探测某语义键是否存在(单次 evaluate,无等待循环,故多候选代价低) */
49
+ async existsKey(key) {
50
+ for (const css of this.candidates(key))
51
+ if (await this.exists(css))
52
+ return true;
53
+ return false;
54
+ }
55
+ /**
56
+ * 按候选顺序点击某语义键:先探测哪个候选存在(避免每个候选各自打满超时),
57
+ * 再对命中的那个执行坐标点击。全部候选都不存在则抛错并附诊断。
58
+ */
59
+ async clickKey(key, text, index) {
60
+ const cands = this.candidates(key);
61
+ for (const css of cands) {
62
+ if (await this.exists(css)) {
63
+ await this.click(css, text, index);
64
+ return;
65
+ }
66
+ }
67
+ const labels = await this.visibleLabels();
68
+ throw new Error(`qoder_control_missing_or_ambiguous: ${key}; candidates=${cands.join(' , ')}${labels.length ? `; 页面可见候选=[${labels.join(' | ')}]` : ''}`);
69
+ }
36
70
  async poll() {
37
- const s = Object.fromEntries(Object.keys(QODER_SELECTORS).map(k => [k, this.selector(k)]));
71
+ const s = Object.fromEntries(Object.keys(QODER_SELECTORS).map(k => [k, this.candidates(k).join(',')]));
38
72
  return this.evaluate(`(()=>{
39
73
  const s=${JSON.stringify(s)},visible=e=>!!e&&!!e.getBoundingClientRect().width&&!e.hidden;
40
74
  const user=[...document.querySelectorAll(s.userMessage)].at(-1);
@@ -87,8 +121,10 @@ export class QoderCdpClient {
87
121
  if (Date.now() < deadline)
88
122
  await delay(Math.min(100, deadline - Date.now()));
89
123
  } while (Date.now() < deadline);
90
- if (!point)
91
- throw new Error(`qoder_control_missing_or_ambiguous: ${css} ${text ?? ''}`);
124
+ if (!point) {
125
+ const labels = await this.visibleLabels();
126
+ throw new Error(`qoder_control_missing_or_ambiguous: ${css} ${text ?? ''}${labels.length ? `; 页面可见候选=[${labels.join(' | ')}]` : ''}`);
127
+ }
92
128
  await this.client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 });
93
129
  await this.client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 });
94
130
  }