dsh-eda-mcp 0.7.2 → 0.7.4

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.md CHANGED
@@ -1,5 +1,118 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4 — 2026-10-20
4
+
5
+ 发布后补:v0.7.3 已上 npm,但它的两个流水线问题和两处诚实性问题是在 tag 之后才修的,这一版把它们发出去。
6
+
7
+ ### 诚实性修复(宿主侧,线协议不变 = 0.4.2,**无需重导扩展**)
8
+
9
+ - **`opened` 必须表示"目标文档真的被激活了"**:导入新扩展会重启 EDA,应用回到 Start Page(`tab_page1`),旧实现靠"标签数 > 1"猜,于是又谎报 `opened: true`。现在要求 `activeDocumentUuid === documentUuid`,新增 `activated` 字段,提示里直接写"先 `eda_open_project` 再开文档"。
10
+ - **空 BOM 不再装成功**:PCB 活动时 `getBomFile` 返回 null,原来给 `{bom:"", encoding:"unknown"}`,看着像"BOM 是空的"。现在直接报错并给出修复动作(切到原理图页)。
11
+
12
+ ### CI 两处真 bug(这就是 v0.7.3 一度没上 npm 的原因)
13
+
14
+ - **`publish.yml` 的守卫误杀自己的文档字符串**:守卫用 `Select-String -Path lib/*.js -Pattern 'node_modules/'`,而它命中我写进技能第 12 章的正确文案(`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`),于是 `exit 1`,`Publish` 步骤从未执行。已抽成 `scripts/guard-bundle-paths.mjs`(`pnpm guard:bundle`),只认**绝对路径形态**的构建机泄露(`D:/…/node_modules`、`/home/`、`/Users/`、`/root/`、`node_modules/.pnpm`),两个 workflow 共用;并做了负向验证(注入泄露 → exit 1 并报出文件与行号)。
15
+ - **`check.yml` 上传已被删除的别名 zip**:0.7.0 起构建只产出 `dsh-eda-bridge-extension-<版本>.zip`,而 workflow 仍指向 `dsh-eda-bridge-extension.zip` 且 `if-no-files-found: error`,导致 check 一直红。改为版本号通配,并把 `guard:bundle` 加进 check。
16
+ - **防复发**:`test/seams.test.ts` 新增 6 条断言 —— check.yml 必须用版本号通配、两个 workflow 都必须调用共享守卫、不得内联重写守卫、`guard:bundle` 必须在 `pnpm check` 链里。
17
+
18
+ ### 仓库卫生
19
+
20
+ - `.tmp-*` 加入 `.gitignore`,并删掉之前误提交的临时文件(提交信息临时文件曾随 `git add -A` 进库)。
21
+
22
+ ### 门禁
23
+
24
+ `pnpm check` = typecheck → 6 套单测(含 seams 新增断言)→ `contract-check` → `build` → **`guard:bundle`**;本次发布前全部复跑通过。
25
+
26
+ ## 0.7.3 — 2026-10-20
27
+
28
+ 本版把原理图与 PCB 两侧都跑了一遍真机,修掉 5 个真问题,并把「三层改动的生效范围」写进随包文档,避免新会话无从下手。
29
+
30
+ ### 真机验证 0.7.2 时发现并修掉的 3 个问题(线协议 0.4.0 → **0.4.1**)
31
+
32
+ > 线协议升到 0.4.1(扩展版本只递增最小位,不跳 minor):**需要重新导入 `dsh-eda-bridge-extension-0.4.1.zip`**(扩展代码有改动)。
33
+
34
+ **1. BOM CSV 仍乱码(真因在扩展)**
35
+
36
+ - 现象:`eda_export_bom` 返回 `��N\u0000o\u0000.`。
37
+ - 排查:桥接侧的 `decodeTextPayload` 没救回,因为字节在**扩展**里就按 UTF-8 解成替换字符了(`blobToFilePayload` 对所有 `text/*` 一律 `TextDecoder("utf-8")`)。
38
+ - 修复:扩展按**BOM 判定编码**(`FF FE`→utf-16le、`FE FF`→utf-16be、`EF BB BF`→utf-8),并在返回值里带 `encoding`;桥接侧解码保留为旧扩展兜底,返回值新增 `encoding` 字段。
39
+
40
+ **2. `eda_open_document` 静默不建标签页**
41
+
42
+ - 现象:应用刚启动停在 HOME 时,`dmt_EditorControl.openDocument(uuid)` 返回空且**不创建标签页**,随后所有 tab 相关调用读空——调用方以为页面已激活。
43
+ - 修复:调用后 600 ms 复查 `getSplitScreenTree` 与活动文档,返回 `opened` / `activeDocumentUuid` / `tabIds`;未建标签页时给 `hint`("该构建不会从 HOME 程序化打开文档,请先在应用里点开页面,别假设已激活")。真机复测:工程加载完成后 `opened:true`,标签列表含原理图页与 PCB 页。
44
+
45
+ **3. 构建会把旧版本 zip 一起打包**
46
+
47
+ - 现象:升版本后目录里同时存在新旧两个扩展 zip,而 `package.json files` 用的是 `dsh-eda-bridge-extension-*.zip` 通配 → 两个都会进 npm 包。
48
+ - 修复:构建前**清掉所有** `dsh-eda-bridge-extension*.zip`;`npm pack --dry-run` 现在只有 1 个 zip(40 files)。
49
+
50
+
51
+
52
+ ### 真机验证 PCB 侧:又抓到一个拦截 bug(线协议 0.4.1 → **0.4.2**)
53
+
54
+ **1. 所有导出(BOM / 网表 / Gerber)本该被第二张白名单挡住**
55
+
56
+ - 现象:`eda_pcb_export_gerber` 报 `API method not allowed: pcb_ManufactureData.getGerberFile`。
57
+ - 真因:扩展的分发门只查 `ALLOWED_API`,而 `FILE_METHODS`(返回 File 的方法:BOM、网表、Gerber、截图)是**第二张表**,被门挡在外面 —— 上线以来所有 `FILE_METHODS` 导出其实都走不通(BOM/网表"能用"是因为它们同时也在 `ALLOWED_API` 里)。
58
+ - 修复:门改为 `!ALLOWED_API.has(m) && !FILE_METHODS.has(m)`,报错信息里给出两表合计数量;`contract-check` 增加一条断言:**分发门必须同时接受 FILE_METHODS**(之前只校验两表并集,所以漏了)。
59
+
60
+ **2. PCB 写入没有校验网络是否存在**
61
+
62
+ - 现象:`pcb_draw_track({ net: "NO_SUCH_NET" })` **被应用接受**,板上多出一条幽灵网络的线(DRC 会报 netlist error)。验证时实际留下了一条,已用 `eda_pcb_delete_track` 清掉(98 条线、0 条幽灵)。
63
+ - 修复:`pcb_draw_track` / `pcb_draw_via` 写入前用 `pcb_Net.getAllNetsName` 校验网络是否存在,不存在则拒绝并列出前 12 个已知网络;`force:true` 仍可强制。真机复测:两条都被拒绝,提示里带 17 个真实网络名。
64
+
65
+ ### PCB 侧真机验证结论(EDA 3.2.186 / 扩展 0.4.1)
66
+
67
+ | 项 | 结果 |
68
+ |---|---|
69
+ | `eda_open_document(pcb)` | ✅ 活动文档 `documentType:3`,tabId 带 `@projectUuid` |
70
+ | `pcb_get_layers` | ✅ 289 个层(id/name/type/color) |
71
+ | `pcb_get_nets` | ✅ 17 个网络 |
72
+ | `pcb_get_components` | ✅ 33 个器件,含封装与 LCSC 料号 |
73
+ | `eda_pcb_get_drc_rules` | ✅ 返回完整规则配置(间距矩阵等) |
74
+ | `eda_pcb_run_drc({includeVerboseError:true})` | ✅ **2.8 s** 返回结构化违规(`name/errorType/ruleName/explanation`),本次 1 条:Netlist Error(PCB 与原理图不一致) |
75
+ | `eda_design_audit({drc:false})` | ✅ 返回 `counts/minWidthByNet/violations/warnings/checklist/rules`(1 violation、1 warning) |
76
+ | `pcb_draw_via`(孔径 0.1mm) | ✅ 拒绝:孔径低于 0.2mm 且焊环 0.1mm 低于 0.2mm |
77
+ | `pcb_draw_track`(未知网络) | ❌→✅ 修复后拒绝(见上) |
78
+ | `eda_pcb_export_gerber` | ❌ `API method not allowed` → 已修,需重导 **0.4.2** 复测 |
79
+ ### 批量标签改用受控并发(实测 2.2 倍)
80
+
81
+ - `eda_place_net_labels` 原来是串行 for 循环:真机实测**每个标签约 2.1 s**(应用按图元落库),133 个仍需 ~4.6 分钟。
82
+ - 改为 `mapLimit` 受控并发(默认 4,可用 `concurrency` 调 1–8),结果仍按调用方顺序返回。
83
+ - 真机复测:**8 个标签 7.5 s = 938 ms/个**(串行 2.1 s/个),清理后计数 19 → 11。
84
+ ### 真机验证结论(EDA 3.2.186 + 扩展 0.4.0)
85
+
86
+ | 项 | 结果 |
87
+ |---|---|
88
+ | `eda_status` / `eda_launch` | ✅ 走 explorer 启动,8.7 s 内 1 个客户端连上,版本一致 |
89
+ | `eda_list_projects` | ✅ 7 个工程(跨 2 个本地目录聚合) |
90
+ | `eda_open_project({name})` | ✅ 中文名解析并打开,`opened:true, openReturned:true` |
91
+ | `eda_export_project` | ✅ 1 056 444 字节 |
92
+ | `eda_export_netlist` | ✅ `parsed:true`,80 647 字节,返回真实 netlist 对象(含 `DC1` 等器件) |
93
+ | `eda_get_components` / `eda_get_pins` | ✅ 34 个器件;引脚返回 `pinNumber` / `pinName`(3/3 有值) |
94
+ | `eda_get_net_labels` | ✅ `count:11, attributeCount:0, textCount:11`(以前恒为 0) |
95
+ | `eda_place_net_labels`(批量 3 个) | ✅ 单次调用 7.0 s、`done:3 failed:0`、`method:"text"`;计数 11 → 14,`sessionTextLabels:3` |
96
+ | 批量标签清理 | ✅ `eda_delete_primitive(kind:"text")` ×3 后回到 11,未保存文档 |
97
+ | `eda_zoom_to_fit` | ✅ 应用返回的越界 bounds(-121…1776 / -2.5…1172)被裁剪为 `0..1170 / 0..825` 并重新应用 |
98
+ | `eda_run_drc` | ⚠️ 只能拿到聚合项 `{type:"warn",count:1}`:即使 `includeVerboseError:true`,该构建也不返回 primitiveId/坐标。工具说明与 hint 已按实情改写 |
99
+ | `eda_export_bom`(真机) | ❌ → 已修(见上),需重导 0.4.1 后复测 |
100
+
101
+
102
+ ### 发布流水线修掉两个 CI bug(v0.7.3 因此没上 npm)
103
+
104
+ **1. `publish.yml` 的"无构建机路径"守卫误杀**
105
+
106
+ - 守卫用 `Select-String -Pattern 'node_modules/'` 扫 `lib/*.js`,命中了我自己写的**文档字符串**`扩展 zip 位置:node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`(这是要告诉用户的正确信息,不是泄露的构建机路径),守卫直接 `exit 1`,**publish 步骤从没执行**。
107
+ - 修复:守卫抽成 `scripts/guard-bundle-paths.mjs`(`pnpm guard:bundle`),只认**绝对路径**形态的泄露(`D:/…/node_modules`、`/home/`、`/Users/`、`/root/`、`node_modules/.pnpm`),两个 workflow 共用同一实现。
108
+ - 真机验证:注入 `D:/Program/…/node_modules/.pnpm/x.js` → 守卫 `exit 1` 并报出文件与行号;恢复后 PASS。
109
+
110
+ **2. `check.yml` 上传的是已被移除的别名 zip**
111
+
112
+ - `path: dsh-eda-bridge-extension.zip` + `if-no-files-found: error`,但构建自 0.7.0 起只产出**带版本号**的 zip(别名是故意删掉的)→ 上传步骤失败,整个 check workflow 红。
113
+ - 修复:改成 `dsh-eda-bridge-extension-*.zip`,并把 `pnpm guard:bundle` 也加进 check。
114
+
115
+ > 影响:`v0.7.3` 的 tag 已推,但 npm 上仍只有 `0.7.2`。补法:在 Actions 里手动运行 `publish` 工作流并勾选 `publish`(`workflow_dispatch` 从 main 取代码,自动带上这两个修复),无需重打 tag。
3
116
  ## 0.7.2 — 2026-10-20
4
117
 
5
118
  本版把原理图批量落地的 15 个问题全部处理完(13 修、2 记为限制),并把工作规范正式注册成 DSH 技能、扩成完整项目手册。
package/README.md CHANGED
@@ -128,6 +128,8 @@ after routing.
128
128
 
129
129
  Schematic/symbol coordinates are in **0.01 inch** units; PCB coordinates are in **mil**. Rotation values are `0/90/180/270`. Wire points are a flat array `[x1, y1, x2, y2, ...]`.
130
130
 
131
+ **What takes effect when** (read this before retrying anything): plugin source in `src/` needs `node build.mjs` plus a **DSH restart** — the running host keeps the old bundle in memory; the EDA extension in `jlc-extension/` needs the zip **re-imported** and JLCEDA Pro restarted (`eda_status.extensionVersions` must match `bridgeVersion`); only the bridge child can be restarted by the agent (`POST /api/dsh-eda-mcp/reconnect`). The generated `docs/schematic-workflow.md` chapter 12 has the full table, and the skill `jlceda-schematic-workflow` carries the same text.
132
+
131
133
  Every PCB tool has an equivalent `eda_pcb_*` alias (`eda_pcb_get_components` = `pcb_get_components`). See `README.zh.md` for the full PCB table and the region-layer rules (`pcb_PrimitiveRegion.create` accepts copper layers and MULTI only, so the board outline stays a closed line loop on layer 11).
132
134
 
133
135
  ## Tool reference
@@ -222,7 +224,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the repository layout, the release/ta
222
224
  - Importing a `.epro2` copies its internal uuids (schematic/page/pcb), which can collide with the source project: `eda_import_project` reports them in `collisions`, but cannot regenerate them.
223
225
  - `dmt_Project.copyProject` is as inert as `createProject` / `deleteProject` for local projects; copy with `eda_export_project` + `eda_import_project` instead.
224
226
  - Automatic layout/routing is off by default (`allowAutoLayoutRouting`); `eda_auto_layout` / `eda_auto_route` only run with `force:true`.
225
- - The extension and bridge wire versions must move **together** (currently 0.4.0): a changed allow-list requires re-importing the zip, otherwise new tools answer `API method not allowed`. `eda_status` reports every connected page's version in `extensionVersions`.
227
+ - The extension and bridge wire versions must move **together** (currently 0.4.1, patch-only bumps): a changed allow-list requires re-importing the zip, otherwise new tools answer `API method not allowed`. `eda_status` reports every connected page's version in `extensionVersions`.
226
228
 
227
229
  ## Repository
228
230
 
package/README.zh.md CHANGED
@@ -117,6 +117,16 @@ curl.exe -s -X POST http://127.0.0.1:19387/api/dsh-eda-mcp/self-test
117
117
 
118
118
  > 桌面端加载的是打包后的 `lib/index.js`,**改完 `src/` 必须重新 `node build.mjs` 并重启桌面应用**(插件重载不会重新 import 已缓存的 ESM 模块)。
119
119
 
120
+ **三层改动的生效范围(新会话/接手的人先看这张表,别凭直觉重试)**:
121
+
122
+ | 改了什么 | 怎么生效 | 怎么确认 |
123
+ |---|---|---|
124
+ | 插件源码 `src/`(配置、路由、技能注册、工具表) | `node build.mjs` 后**重启 DSH** | 会话出现新工具;`/api/dsh-eda-mcp/diagnostics` 的 `skills` 字段 |
125
+ | 扩展 `jlc-extension/`(白名单、内部助手、编码) | **重新导入** `dsh-eda-bridge-extension-<版本>.zip` 并**重启嘉立创EDA** | `eda_status` 的 `extensionVersions` 与 `bridgeVersion` 一致 |
126
+ | 只需重启桥接子进程 | `POST /api/dsh-eda-mcp/reconnect`(agent 可自行) | 返回 200 且 `connectedClients ≥ 1` |
127
+
128
+ 完整版(含排查顺序与版本纪律)见生成文档 `docs/schematic-workflow.md` 第 12 章,或会话内 `skill` 加载 `jlceda-schematic-workflow`。
129
+
120
130
  ### 4. 在嘉立创EDA里手动连接
121
131
 
122
132
  DSH 桥接服务开着但 EDA 没有连上时,有三种入口:
@@ -384,7 +394,7 @@ pnpm check # 以上三步
384
394
  - 半离线模式下 EDA 把**每个本地工程目录当成一个 "team"**:`eda_list_teams` 返回的 `uuid` 就是目录路径,而工程记录里的 `teamUuid` 是另一种真 uuid。`eda_list_projects` 无过滤时会遍历所有 team 聚合,因此第一次调用会比带 `teamUuid` 时慢一些。
385
395
  - `lceda-pro.exe` 直接 `spawn`(含 ShellExecute)会**立刻以退出码 0 退出**:只有交给桌面外壳(`explorer.exe`)才能起来,所以 `eda_launch` 在 Windows 上固定走 explorer + 临时 `.cmd`。
386
396
  - 命令行带工程路径启动**被应用忽略**(活动文档仍是 HOME),所以 `eda_launch({ project })` 只保证"把参数传下去",返回值里 `projectOpenedByArgument: false` 与 `projectNote` 会明确说明;要打开工程请用 `eda_open_project`。
387
- - 扩展与桥接的**线协议版本必须成对升级**(当前 0.4.0):白名单变了就要重新导入 zip,否则新工具会报 `API method not allowed`;`eda_status` 的 `extensionVersions` 会指出每个页面连上来的版本。
397
+ - 扩展与桥接的**线协议版本必须成对升级**(当前 0.4.1,只递增最小位):白名单变了就要重新导入 zip,否则新工具会报 `API method not allowed`;`eda_status` 的 `extensionVersions` 会指出每个页面连上来的版本。
388
398
 
389
399
  ## 仓库结构
390
400
 
package/docs/release.md CHANGED
@@ -162,6 +162,8 @@ CHANGELOG 里要写明「是否需要重新导入扩展」。
162
162
  ```
163
163
 
164
164
  - [ ] `CHANGELOG.md` 把该版本标为「已发布到 npm」
165
+ - [ ] 发布失败要补发时**不要重打 tag**:在 Actions 里手动运行 `publish`,勾选 `publish` 输入(`workflow_dispatch` 从 main 取代码,自动带上失败之后的所有修复;tag 校验只在 tag 触发时生效)。
166
+ - [ ] 流水线自身失败时先本地复跑同一条命令:`pnpm check`(= typecheck + 6 套单测 + contract-check + build + guard:bundle)
165
167
  - [ ] npmmirror 已同步(`dist-tags.latest` 一致)
166
168
 
167
169
  ## 7. 已知失败模式速查
@@ -110,6 +110,7 @@
110
110
  | connectedClients=0 / 报未连接 | EDA 没开、扩展没导入或没点「连接」、端口被占 | 先 eda_status 看 hint;必要时 eda_launch 起应用并等扩展连上 |
111
111
  | extensionVersions 与 bridgeVersion 不一致 | 装的是旧扩展 | 重新导入对应版本 zip(文件名带版本号)并重启应用 |
112
112
  | API method not allowed | 该 API 不在扩展白名单 | 调 eda_list_api_methods 看清单;优先用包装好的工具 |
113
+ | open_document 返回 opened:false / activated:false | 应用停在 Start Page(tab_page1),该构建无法从 HOME 程序化打开文档 | 先 eda_open_project 打开工程,再 eda_open_document;用 activeDocumentUuid 确认,别假设已激活 |
113
114
  | 调用 60s 超时 | 页面切走留下僵尸 socket,或单次改太多 | 只读调用会自动换页面重试一次;把批量拆到 ≤20/批 |
114
115
  | 读图返回空 / null | 活动文档不是原理图页 | eda_get_active_document → eda_open_document 切页 |
115
116
  | createNetLabel 无响应 | 3.2.186 该 API 不 resolve | 自动降级文本标签 + stub 导线;批量用 eda_place_net_labels |
@@ -150,3 +151,20 @@ C. 只做 PCB:
150
151
  - [ ] 视觉:导出图片自查,标签不压符号、器件不重叠、功能框清晰。
151
152
  - [ ] 保存:文档已保存,工程已 eda_export_project 备份。
152
153
  - [ ] Gerber 已导出且层数与板层一致。
154
+
155
+ ## 12. 环境与生效规则(全新会话必读)
156
+
157
+ 这套工具分三层,改动生效范围完全不同——不确定时先看这张表,不要凭直觉重试。
158
+
159
+ | 改了什么 | 谁来做 | 怎么生效 | 怎么确认 |
160
+ |---|---|---|---|
161
+ | 插件源码 `src/`(宿主侧:配置、路由、技能注册、MCP 工具表) | 开发者 | `node build.mjs` 后**重启 DSH**(桌面端关掉重开) | 会话里能看到新工具;`/api/dsh-eda-mcp/diagnostics` 的 `skills` 字段 |
162
+ | 扩展 `jlc-extension/`(白名单、内部助手、编码处理) | 用户 | 嘉立创EDA 里**重新导入** `dsh-eda-bridge-extension-<版本>.zip` 并**重启应用** | `eda_status` 的 `extensionVersions` 与 `bridgeVersion` 一致 |
163
+ | 只需重启桥接子进程(改了 `lib/bridge.mjs`、换端口、卡住) | agent 可自行 | `POST /api/dsh-eda-mcp/reconnect` | 返回 200 且 `eda_status.connectedClients ≥ 1` |
164
+ | 没改任何东西但工具莫名消失 | agent | 先 reconnect;仍不行看 `/diagnostics` 的 `clientStartupError` | 工具重新出现在会话里 |
165
+
166
+ - **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。
167
+ - 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。
168
+ - 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。
169
+ - 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。
170
+ - 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。
package/docs/tools.md CHANGED
@@ -71,7 +71,7 @@ Design limits enforced by these tools (track widths, via sizes, net naming) are
71
71
  | `eda_draw_circle` | Draw a circle. Coordinates are MCP units. | `centerX: number, centerY: number, radius: number, color?: string, fillColor?: string, lineWidth?: integer, lineType?: integer, fillStyle?: string` |
72
72
  | `eda_draw_text` | Draw text. Coordinates are MCP units. | `x: number, y: number, content: string, rotation?: integer, textColor?: string, fontName?: string, fontSize?: number, bold?: boolean, italic?: boolean, underLine?: boolean, alignMode?: integer` |
73
73
  | `eda_place_net_label` | Place a net label at x/y. The real API (sch_PrimitiveAttribute.createNetLabel) is probed once per session; when the build never answers, a plain TEXT label is placed instead, which carries no electrical meaning and must be paired with a stub wire. Pass probe:true to re-check. | `x: number, y: number, net: string, fontSize?: number, rotation?: integer` |
74
- | `eda_place_net_labels` | Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }]. | `items: array, probe?: boolean` |
74
+ | `eda_place_net_labels` | Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }]. | `items: array, probe?: boolean, concurrency?: integer` |
75
75
  | `eda_move_component` | Move/rotate/mirror a schematic component (sch_PrimitiveComponent.modify) without deleting and re-placing it, so the designator and value survive. | `primitiveId: string, x?: number, y?: number, rotation?: number, mirror?: boolean` |
76
76
  | `eda_delete_primitive` | Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text". | `kind: string, primitiveId: string` |
77
77
 
@@ -82,7 +82,7 @@ Design limits enforced by these tools (track widths, via sizes, net naming) are
82
82
  | `eda_save_document` | Save the active schematic document. | `` |
83
83
  | `eda_zoom_to_fit` | Zoom to fit all primitives in the active document. | `tabId?: string` |
84
84
  | `eda_zoom_to_region` | Zoom to a region. Schematic coordinates are MCP units. | `left: number, right: number, top: number, bottom: number, tabId?: string` |
85
- | `eda_run_drc` | Run schematic DRC with includeVerboseError and return one entry per problem (type, message, primitiveId, x/y plus the raw record) instead of bare counts. | `strict?: boolean` |
85
+ | `eda_run_drc` | Run schematic DRC with includeVerboseError. On EDA 3.2.186 the app still returns AGGREGATED entries ({type:"warn",count:N}) with no primitiveId/coordinates, so entries are reported as-is and raw keeps the untouched payload; expect counts plus a hint until the app exposes details. | `strict?: boolean` |
86
86
  | `eda_auto_layout` | Run schematic auto-layout (sch_Document.autoLayout). DISABLED by default: automated layout is not used unless the user asks for it. Pass force:true only then. | `uuids?: array, force?: boolean` |
87
87
  | `eda_auto_route` | Run schematic auto-routing (sch_Document.autoRouting). DISABLED by default: route by hand unless the user explicitly asks. Pass force:true only then. | `uuids?: array, force?: boolean` |
88
88
  | `eda_export_netlist` | Export the schematic netlist as text (default JLCEDA format). Returns null when no schematic is active - check eda_get_active_document / open the schematic page first. | `type?: string` |
@@ -326,7 +326,19 @@ function dshEdaMcpStatus() {
326
326
  /(csv|json|net|plain|xml)/i.test(mimeType) ||
327
327
  /\.(csv|json|txt|net|xml)$/i.test(name);
328
328
  if (isText && typeof TextDecoder !== 'undefined') {
329
- return { mimeType: mimeType, name: name, size: bytes.length, text: new TextDecoder('utf-8').decode(bytes) };
329
+ // A blanket UTF-8 decode destroyed text payloads that carry another encoding: the
330
+ // BOM CSV is UTF-16LE, so the bytes became replacement characters before the bridge
331
+ // ever saw them. Detect the BOM and report which decoder ran.
332
+ var encoding = 'utf-8';
333
+ var offset = 0;
334
+ if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE) { encoding = 'utf-16le'; offset = 2; }
335
+ else if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF) { encoding = 'utf-16be'; offset = 2; }
336
+ else if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) { offset = 3; }
337
+ var body = bytes.subarray(offset);
338
+ var text;
339
+ try { text = new TextDecoder(encoding).decode(body); }
340
+ catch (_) { text = new TextDecoder('utf-8').decode(body); encoding = 'utf-8'; }
341
+ return { mimeType: mimeType, name: name, size: bytes.length, encoding: encoding, text: text };
330
342
  }
331
343
  return { mimeType: mimeType, name: name, size: bytes.length, dataUrl: 'data:' + mimeType + ';base64,' + bytesToBase64(bytes) };
332
344
  }
@@ -474,8 +486,12 @@ function dshEdaMcpStatus() {
474
486
  error: JOB.error
475
487
  };
476
488
  }
477
- if (!ALLOWED_API.has(message.method)) {
478
- throw new Error('API method not allowed: ' + String(message.method));
489
+ // Two sets gate the wire: ALLOWED_API for plain calls and FILE_METHODS for the ones
490
+ // that hand a File back. Only checking the first one made every export (BOM,
491
+ // netlist, Gerber) answer "API method not allowed".
492
+ if (!ALLOWED_API.has(message.method) && !FILE_METHODS.has(message.method)) {
493
+ throw new Error('API method not allowed: ' + String(message.method) +
494
+ ' (the extension accepts ' + (ALLOWED_API.size + FILE_METHODS.size) + ' methods: ALLOWED_API + FILE_METHODS)');
479
495
  }
480
496
  if (message.method === 'dmt_EditorControl.getCurrentRenderedAreaImage') {
481
497
  var blob = await callApi(message.method, message.args || []);
@@ -2,7 +2,7 @@
2
2
  "uuid": "d5e0a1b2c3f4456789abcdef01234567",
3
3
  "name": "dsh-eda-bridge",
4
4
  "displayName": "DSH EDA MCP Bridge",
5
- "version": "0.4.0",
5
+ "version": "0.4.2",
6
6
  "description": "Local WebSocket bridge used by the dsh-eda-mcp plugin to let DeepSeek Harness draw schematics and drive PCB operations in JLCEDA Pro through the official extension API.",
7
7
  "entry": "./api-bridge.js",
8
8
  "publisher": "dsh-eda-mcp",
package/lib/bridge.mjs CHANGED
@@ -332,7 +332,7 @@ var WORKFLOW_GUIDE = WORKFLOW_SUMMARY + "\n\n" + [
332
332
  ].join("\n");
333
333
 
334
334
  // src/bridge/index.ts
335
- var VERSION = "0.4.0";
335
+ var VERSION = "0.4.2";
336
336
  var WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009);
337
337
  var WS_HOST = "127.0.0.1";
338
338
  var ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? "1") !== "0";
@@ -363,9 +363,14 @@ function safeAreas(paperName) {
363
363
  function decodeTextPayload(value) {
364
364
  const text = typeof value === "string" ? value : "";
365
365
  if (text === "") return text;
366
- const looksUtf16 = text.charCodeAt(0) === 65279 || text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0;
367
- if (!looksUtf16) return text;
368
- return Buffer.from(text, "latin1").toString("utf16le").replace(String.fromCharCode(65279), "");
366
+ if (text.charCodeAt(0) === 65279) return text.slice(1);
367
+ if (text.charCodeAt(0) === 255 && text.charCodeAt(1) === 254) {
368
+ return Buffer.from(text.slice(2), "latin1").toString("utf16le");
369
+ }
370
+ if (text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0) {
371
+ return Buffer.from(text, "latin1").toString("utf16le");
372
+ }
373
+ return text;
369
374
  }
370
375
  function checkPointInFrame(x, y, paperName, margin = 0) {
371
376
  const paper = paperOf(paperName);
@@ -604,8 +609,8 @@ var TOOLS = [
604
609
  { name: "eda_open_document", description: "Open/activate a document tab by uuid (dmt_EditorControl.openDocument). Works for a schematic page or a PCB, so tab-dependent APIs can run.", inputSchema: objectSchema({ documentUuid: { type: "string" }, splitScreenId: { type: "string" } }, ["documentUuid"]) },
605
610
  { name: "eda_get_net_labels", description: "List every label-like primitive on the active schematic page: attribute primitives (netlabel/netport/netflag) PLUS plain texts left by the createNetLabel fallback, so they can be audited and deleted. Reports kind/primitiveId/net per entry.", inputSchema: objectSchema({ parentPrimitiveId: { type: "string" } }) },
606
611
  { name: "eda_place_net_label", description: "Place a net label at x/y. The real API (sch_PrimitiveAttribute.createNetLabel) is probed once per session; when the build never answers, a plain TEXT label is placed instead, which carries no electrical meaning and must be paired with a stub wire. Pass probe:true to re-check.", inputSchema: objectSchema({ x: { type: "number" }, y: { type: "number" }, net: { type: "string" }, fontSize: { type: "number" }, rotation: { type: "integer", enum: [0, 90, 180, 270] } }, ["x", "y", "net"]) },
607
- { name: "eda_place_net_labels", description: "Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].", inputSchema: objectSchema({ items: { type: "array", items: { type: "object", additionalProperties: true } }, probe: { type: "boolean" } }, ["items"]) },
608
- { name: "eda_run_drc", description: "Run schematic DRC with includeVerboseError and return one entry per problem (type, message, primitiveId, x/y plus the raw record) instead of bare counts.", inputSchema: objectSchema({ strict: { type: "boolean" } }) },
612
+ { name: "eda_place_net_labels", description: "Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].", inputSchema: objectSchema({ items: { type: "array", items: { type: "object", additionalProperties: true } }, probe: { type: "boolean" }, concurrency: { type: "integer", minimum: 1, maximum: 8 } }, ["items"]) },
613
+ { name: "eda_run_drc", description: 'Run schematic DRC with includeVerboseError. On EDA 3.2.186 the app still returns AGGREGATED entries ({type:"warn",count:N}) with no primitiveId/coordinates, so entries are reported as-is and raw keeps the untouched payload; expect counts plus a hint until the app exposes details.', inputSchema: objectSchema({ strict: { type: "boolean" } }) },
609
614
  { name: "eda_auto_layout", description: "Run schematic auto-layout (sch_Document.autoLayout). DISABLED by default: automated layout is not used unless the user asks for it. Pass force:true only then.", inputSchema: objectSchema({ uuids: { type: "array", items: { type: "string" } }, force: { type: "boolean" } }) },
610
615
  { name: "eda_auto_route", description: "Run schematic auto-routing (sch_Document.autoRouting). DISABLED by default: route by hand unless the user explicitly asks. Pass force:true only then.", inputSchema: objectSchema({ uuids: { type: "array", items: { type: "string" } }, force: { type: "boolean" } }) },
611
616
  { name: "eda_export_netlist", description: "Export the schematic netlist as text (default JLCEDA format). Returns null when no schematic is active - check eda_get_active_document / open the schematic page first.", inputSchema: objectSchema({ type: { type: "string" } }) },
@@ -1435,6 +1440,22 @@ function internalIdsOf(info) {
1435
1440
  }
1436
1441
  return ids;
1437
1442
  }
1443
+ async function assertKnownPcbNet(toolName, net, timeoutMs, force) {
1444
+ const name = String(net ?? "").trim();
1445
+ if (name === "") throw new Error(toolName + " requires a net name");
1446
+ if (force === true) return;
1447
+ let names;
1448
+ try {
1449
+ const raw = await callExtension("pcb_Net.getAllNetsName", [], Math.min(timeoutMs, 8e3));
1450
+ names = new Set((Array.isArray(raw) ? raw : []).map((value) => String(value)));
1451
+ } catch {
1452
+ return;
1453
+ }
1454
+ if (names.size === 0) return;
1455
+ if (names.has(name)) return;
1456
+ const sample = [...names].slice(0, 12).join(", ");
1457
+ throw new Error(toolName + ': net "' + name + '" is not on this PCB (' + names.size + " nets: " + sample + "\u2026). A write on an unknown net creates a phantom net that DRC reports as a netlist error; pass force:true if that is really intended.");
1458
+ }
1438
1459
  async function executeTool(name, rawArgs, timeoutMs) {
1439
1460
  const args = asRecord(rawArgs);
1440
1461
  const tool = PCB_TOOL_ALIASES[name] ?? name;
@@ -1981,16 +2002,16 @@ async function executeTool(name, rawArgs, timeoutMs) {
1981
2002
  case "eda_place_net_labels": {
1982
2003
  const items = Array.isArray(args.items) ? args.items.map(asRecord) : [];
1983
2004
  if (items.length === 0) throw new Error("eda_place_net_labels requires items: [{ pinPrimitiveId, net }] or [{ x, y, net }]");
1984
- const results = [];
1985
- for (const item of items) {
2005
+ const concurrency = Number.isFinite(Number(args.concurrency)) ? Math.max(1, Math.min(8, Number(args.concurrency))) : 4;
2006
+ const results = await mapLimit(items, concurrency, async (item) => {
1986
2007
  const net = item.net === void 0 ? "" : String(item.net);
1987
2008
  try {
1988
2009
  const placed = item.pinPrimitiveId !== void 0 ? await placeAtPin("eda_place_net_label_at_pin", { ...item, net }, timeoutMs) : await placeNetLabel(net, Number(item.x), Number(item.y), item.fontSize, timeoutMs, { probe: args.probe === true });
1989
- results.push({ ok: true, net, ...placed });
2010
+ return { ok: true, net, ...placed };
1990
2011
  } catch (error) {
1991
- results.push({ ok: false, net, error: error instanceof Error ? error.message : String(error) });
2012
+ return { ok: false, net, error: error instanceof Error ? error.message : String(error) };
1992
2013
  }
1993
- }
2014
+ });
1994
2015
  const done = results.filter((entry) => entry.ok).length;
1995
2016
  return {
1996
2017
  total: items.length,
@@ -2045,11 +2066,37 @@ async function executeTool(name, rawArgs, timeoutMs) {
2045
2066
  const type = Number(record.documentType);
2046
2067
  return { document: info, documentTypeName: DOCUMENT_TYPES[type] ?? String(record.documentType ?? "unknown"), documentType: record.documentType };
2047
2068
  }
2048
- case "eda_open_document":
2049
- return await callExtension("dmt_EditorControl.openDocument", [
2050
- String(args.documentUuid),
2069
+ case "eda_open_document": {
2070
+ const documentUuid = String(args.documentUuid ?? "");
2071
+ if (documentUuid === "") throw new Error("documentUuid is required");
2072
+ const raw = await callExtension("dmt_EditorControl.openDocument", [
2073
+ documentUuid,
2051
2074
  args.splitScreenId === void 0 ? void 0 : String(args.splitScreenId)
2052
2075
  ], timeoutMs);
2076
+ await sleep(600);
2077
+ let tabIds = [];
2078
+ try {
2079
+ const tree = asRecord(await callExtension("dmt_EditorControl.getSplitScreenTree", [], Math.min(timeoutMs, 4e3)));
2080
+ tabIds = (Array.isArray(tree.tabs) ? tree.tabs : []).map((tab) => String(pickValue(asRecord(tab), "tabId") ?? ""));
2081
+ } catch {
2082
+ }
2083
+ let activeUuid = "";
2084
+ try {
2085
+ const info = asRecord(await callExtension("dmt_SelectControl.getCurrentDocumentInfo", [], Math.min(timeoutMs, 4e3)));
2086
+ activeUuid = String(pickValue(info, "uuid", "Uuid") ?? "");
2087
+ } catch {
2088
+ }
2089
+ const activeIsTarget = activeUuid === documentUuid;
2090
+ const tabOpened = activeIsTarget || tabIds.some((tab) => tab.startsWith(documentUuid + "@"));
2091
+ return {
2092
+ ...raw !== null && typeof raw === "object" ? raw : { result: raw ?? null },
2093
+ opened: tabOpened,
2094
+ activated: activeIsTarget,
2095
+ activeDocumentUuid: activeUuid || null,
2096
+ tabIds,
2097
+ hint: tabOpened ? null : "The app accepted openDocument but the document is not active (activeDocumentUuid=" + (activeUuid || "unknown") + ", tabs: " + (tabIds.join(", ") || "none") + "). After an app restart the editor sits on the Start Page: open the project first (eda_open_project), then open the document. This build cannot open a document from the HOME screen programmatically, so never assume a page is active without checking activeDocumentUuid."
2098
+ };
2099
+ }
2053
2100
  case "eda_set_no_connect": {
2054
2101
  const ids = Array.isArray(args.primitiveIds) ? args.primitiveIds.map(String) : [];
2055
2102
  if (ids.length === 0) throw new Error("primitiveIds is required");
@@ -2254,7 +2301,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2254
2301
  raw: entry
2255
2302
  };
2256
2303
  }),
2257
- hint: 'Field names vary by EDA build; "raw" keeps the untouched record.'
2304
+ hint: 'EDA 3.2.186 returns AGGREGATED entries only (e.g. {type:"warn",count:1}) even with includeVerboseError, so message/primitiveId stay null \u2014 "raw" carries exactly what the app sent. Per-problem coordinates are not available on this build; use eda_pcb_run_drc for the PCB side and the DRC panel for details.'
2258
2305
  };
2259
2306
  }
2260
2307
  return { ok: Boolean(verbose), count: null, entries: [], raw: verbose };
@@ -2297,7 +2344,11 @@ async function executeTool(name, rawArgs, timeoutMs) {
2297
2344
  const payload = await callExtension("sch_ManufactureData.getBomFile", [void 0, format], toolTimeout(args, timeoutMs));
2298
2345
  if (format === "xlsx") return payload;
2299
2346
  const raw = typeof payload === "string" ? payload : payload !== null && typeof payload === "object" && typeof payload.text === "string" ? payload.text : "";
2300
- return { format, bom: decodeTextPayload(raw) };
2347
+ if (raw === "") {
2348
+ throw new Error("eda_export_bom: no BOM data (the schematic page must be the active document; call eda_get_active_document, then eda_open_document on the schematic page and retry)");
2349
+ }
2350
+ const encoding = payload !== null && typeof payload === "object" ? payload.encoding : void 0;
2351
+ return { format, bom: decodeTextPayload(raw), encoding: encoding ?? "unknown" };
2301
2352
  }
2302
2353
  case "eda_delete_primitive": {
2303
2354
  const kind = String(args.kind);
@@ -2387,6 +2438,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2387
2438
  if (layer === void 0) throw new Error("pcb_draw_track requires a valid layer");
2388
2439
  const net = String(args.net);
2389
2440
  assertNetName(net, "pcb_draw_track");
2441
+ await assertKnownPcbNet("pcb_draw_track", net, timeoutMs, args.force === true);
2390
2442
  const width = checkTrackWidth(net, args.lineWidth === void 0 ? void 0 : Number(args.lineWidth), args.force === true);
2391
2443
  if (!width.ok) throw new Error("pcb_draw_track: " + width.errors.join("; "));
2392
2444
  const created = await callExtension("pcb_PrimitiveLine.create", [
@@ -2403,6 +2455,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2403
2455
  case "pcb_draw_via": {
2404
2456
  const net = String(args.net);
2405
2457
  assertNetName(net, "pcb_draw_via");
2458
+ await assertKnownPcbNet("pcb_draw_via", net, timeoutMs, args.force === true);
2406
2459
  const role = classifyNet(net);
2407
2460
  const defaultVia = role === "signal" ? VIA_SIZES_MM[0] : VIA_SIZES_MM[1];
2408
2461
  const hole = args.holeDiameter === void 0 ? defaultVia.hole * 39.37007874015748 : Number(args.holeDiameter);
package/lib/index.js CHANGED
@@ -19,6 +19,7 @@ var EDA_MCP_GUIDANCE = [
19
19
  "\u7ED8\u5236\u7EC6\u8282\uFF1A\u539F\u7406\u56FE\u5750\u6807\u4E3A 0.01inch\uFF0C\u539F\u70B9\u5728 A4 \u56FE\u6846\u5DE6\u4E0B\u89D2\uFF0Cy \u5411\u4E0A\uFF0C\u56FE\u6846 x 0~1170\u3001y 0~825\uFF0C\u6807\u9898\u680F x 460~1160\u3001y 10~190 \u5FC5\u987B\u907F\u8BA9\uFF08\u5148 eda_get_page_info\uFF09\u3002PCB \u5750\u6807\u4E3A mil\u3002\u677F\u6846\u7528 pcb_draw_board_outline\uFF08\u677F\u6846\u5C42 11 \u7684\u4E00\u6761 pcb_PrimitivePolyline\uFF0C\u7EBF\u5BBD\u9ED8\u8BA4 10mil\uFF09\uFF1B\u533A\u57DF\u53EA\u80FD\u7528\u94DC\u5C42 top/bottom/\u5185\u5C42(15\u201344)/MULTI(12)\u3002",
20
20
  "\u957F\u4EFB\u52A1\uFF1Aeda_pcb_auto_route \u9ED8\u8BA4\u5F02\u6B65\u542F\u52A8\uFF08EDA \u81EA\u5DF1\u663E\u793A\u8FDB\u5EA6\u6761\uFF09\uFF0C\u5FC5\u987B\u7528 eda_pcb_job_status \u8F6E\u8BE2 running \u76F4\u5230 false\uFF0C\u4E0D\u8981\u52A0\u5927\u8D85\u65F6\uFF1Beda_pcb_clear_routing \u540C\u7406\uFF08\u65E7\u6269\u5C55\u81EA\u52A8\u5206\u6279\uFF0C\u6309 done \u7EE7\u7EED\u8C03\u7528\uFF09\u3002",
21
21
  "\u539F\u7406\u56FE API\uFF08sch_Net.*\u3001\u7F51\u8868\u5BFC\u51FA\uFF09\u53EA\u4F5C\u7528\u4E8E\u5F53\u524D\u6D3B\u52A8\u6587\u6863\uFF1A\u8FD4\u56DE\u7A7A\u65F6\u5148\u7528 eda_get_active_document \u67E5\u3001eda_open_document \u5207\u5230\u539F\u7406\u56FE\u9875\u3002",
22
+ "\u751F\u6548\u8303\u56F4\uFF08\u6539\u52A8\u4E0D\u70ED\u66F4\u65B0\uFF09\uFF1A\u63D2\u4EF6\u6E90\u7801 src/ \u6539\u52A8\u8981 node build.mjs \u540E\u91CD\u542F DSH\uFF0C\u5F53\u524D\u4F1A\u8BDD\u4E0D\u4F1A\u751F\u6548\uFF1B\u6269\u5C55 jlc-extension/ \u6539\u52A8\u8981\u91CD\u65B0\u5BFC\u5165 dsh-eda-bridge-extension-<\u7248\u672C>.zip \u5E76\u91CD\u542F\u5609\u7ACB\u521BEDA\uFF08eda_status \u7684 extensionVersions \u5FC5\u987B\u4E0E bridgeVersion \u4E00\u81F4\uFF09\uFF1B\u53EA\u6709\u91CD\u542F\u6865\u63A5\u5B50\u8FDB\u7A0B\u80FD\u7531 agent \u81EA\u5DF1\u505A\uFF08POST /api/dsh-eda-mcp/reconnect\uFF09\u3002\u6392\u67E5\u51FA\u53E3\uFF1A/api/dsh-eda-mcp/diagnostics \u7684 clientStartupError / skills / bridgeHealth\u3002\u6269\u5C55\u7248\u672C\u53EA\u9012\u589E\u6700\u5C0F\u4F4D\u3002",
22
23
  '\u539F\u7406\u56FE\u5DE5\u4F5C\u89C4\u8303\uFF08\u9ED8\u8BA4\u624B\u5DE5\uFF0C\u7981\u6B62\u4E3A\u7701\u4E8B\u7528\u81EA\u52A8\u5E03\u5C40/\u81EA\u52A8\u5E03\u7EBF\uFF09\uFF1A\u6309 10 \u5355\u4F4D\u6805\u683C\u5BF9\u9F50\uFF1B\u4FE1\u53F7\u5DE6\u2192\u53F3\u3001\u7535\u6E90\u5728\u4E0A\u3001\u5730\u5728\u4E0B\uFF1B\u5668\u4EF6\u95F4\u7559 \u226520 \u5355\u4F4D\u8D70\u7EBF\u4E0E\u6807\u7B7E\u7A7A\u95F4\uFF1B\u5148 eda_get_page_info \u770B\u5B89\u5168\u533A\u518D\u6446\u4F4D\uFF1B\u6446\u5B8C\u7528 eda_get_components \u68C0\u67E5\u5305\u56F4\u76D2\u4E0D\u91CD\u53E0\u3002eda_auto_layout / eda_auto_route \u9ED8\u8BA4\u88AB\u62D2\u7EDD\uFF08\u9700 force:true \u4E14\u8BBE\u7F6E\u91CC\u653E\u884C\uFF09\uFF0C\u4EC5\u5F53\u7528\u6237\u660E\u786E\u8981\u6C42\u65F6\u4F7F\u7528\uFF1B\u7248\u9762\u68C0\u67E5\u4F18\u5148"\u5BFC\u51FA\u56FE\u7247\u81EA\u884C\u8BC6\u522B"\u800C\u4E0D\u662F\u8BA9 EDA \u91CD\u6392\u3002',
23
24
  '\u8DE8\u533A\u57DF\u540C\u540D\u7F51\u7EDC\u5FC5\u987B\u7528 eda_place_net_port / \u7F51\u7EDC\u6807\u7B7E\uFF1Aeda_draw_wire \u7684 net \u53EA\u5BF9\u60AC\u7A7A\u5BFC\u7EBF\u6709\u6548\uFF0C\u5BFC\u7EBF\u4E00\u65E6\u63A5\u89E6\u5F15\u811A\uFF0CEDA \u4F1A\u6309\u8FDE\u901A\u6027\u91CD\u7B97\u7F51\u7EDC\u540D\uFF08\u5B9E\u6D4B TESTNET \u88AB\u5E76\u5165 LED_A\uFF09\uFF0C\u6240\u4EE5"\u540C\u540D\u5BFC\u7EBF = \u540C\u540D\u7F51\u7EDC"\u4E0D\u6210\u7ACB\u3002',
24
25
  "\u7F51\u7EDC\u6807\u7B7E\uFF1Aeda_place_net_label \u5148\u771F\u5B9E\u8C03 createNetLabel\uFF0C\u8BE5 API \u5728 3.2.186 \u4E0A\u4E0D\u54CD\u5E94 \u2192 \u63A2\u6D4B\u7ED3\u8BBA\u6309\u4F1A\u8BDD\u7F13\u5B58\u540E\u81EA\u52A8\u964D\u7EA7\u4E3A\u7EAF\u6587\u672C\u6807\u7B7E\uFF08\u65E0\u7535\u6C14\u8BED\u4E49\uFF0C\u9760 stub \u5BFC\u7EBF\u627F\u8F7D\u7F51\u7EDC\uFF09\uFF1B\u6279\u91CF\u52A1\u5FC5\u7528 eda_place_net_labels \u4E00\u6B21\u8C03\u7528\uFF0C\u4E0D\u8981\u4E32\u884C\u4E0A\u767E\u6B21\uFF08\u4F1A\u6253\u7206 run_code \u8D85\u65F6\u4E14\u6CA1\u6709\u8FDB\u5EA6\uFF09\u3002\u6807\u7B7E\u53EF\u679A\u4E3E/\u6E05\u7406\uFF1Aeda_get_net_labels + eda_delete_primitive(kind: netlabel|netport|netflag|text)\u3002",
@@ -137,6 +138,7 @@ var SKILL_PLAYBOOK_LINES = [
137
138
  "| connectedClients=0 / \u62A5\u672A\u8FDE\u63A5 | EDA \u6CA1\u5F00\u3001\u6269\u5C55\u6CA1\u5BFC\u5165\u6216\u6CA1\u70B9\u300C\u8FDE\u63A5\u300D\u3001\u7AEF\u53E3\u88AB\u5360 | \u5148 eda_status \u770B hint\uFF1B\u5FC5\u8981\u65F6 eda_launch \u8D77\u5E94\u7528\u5E76\u7B49\u6269\u5C55\u8FDE\u4E0A |",
138
139
  "| extensionVersions \u4E0E bridgeVersion \u4E0D\u4E00\u81F4 | \u88C5\u7684\u662F\u65E7\u6269\u5C55 | \u91CD\u65B0\u5BFC\u5165\u5BF9\u5E94\u7248\u672C zip\uFF08\u6587\u4EF6\u540D\u5E26\u7248\u672C\u53F7\uFF09\u5E76\u91CD\u542F\u5E94\u7528 |",
139
140
  "| API method not allowed | \u8BE5 API \u4E0D\u5728\u6269\u5C55\u767D\u540D\u5355 | \u8C03 eda_list_api_methods \u770B\u6E05\u5355\uFF1B\u4F18\u5148\u7528\u5305\u88C5\u597D\u7684\u5DE5\u5177 |",
141
+ "| open_document \u8FD4\u56DE opened:false / activated:false | \u5E94\u7528\u505C\u5728 Start Page\uFF08tab_page1\uFF09\uFF0C\u8BE5\u6784\u5EFA\u65E0\u6CD5\u4ECE HOME \u7A0B\u5E8F\u5316\u6253\u5F00\u6587\u6863 | \u5148 eda_open_project \u6253\u5F00\u5DE5\u7A0B\uFF0C\u518D eda_open_document\uFF1B\u7528 activeDocumentUuid \u786E\u8BA4\uFF0C\u522B\u5047\u8BBE\u5DF2\u6FC0\u6D3B |",
140
142
  "| \u8C03\u7528 60s \u8D85\u65F6 | \u9875\u9762\u5207\u8D70\u7559\u4E0B\u50F5\u5C38 socket\uFF0C\u6216\u5355\u6B21\u6539\u592A\u591A | \u53EA\u8BFB\u8C03\u7528\u4F1A\u81EA\u52A8\u6362\u9875\u9762\u91CD\u8BD5\u4E00\u6B21\uFF1B\u628A\u6279\u91CF\u62C6\u5230 \u226420/\u6279 |",
141
143
  "| \u8BFB\u56FE\u8FD4\u56DE\u7A7A / null | \u6D3B\u52A8\u6587\u6863\u4E0D\u662F\u539F\u7406\u56FE\u9875 | eda_get_active_document \u2192 eda_open_document \u5207\u9875 |",
142
144
  "| createNetLabel \u65E0\u54CD\u5E94 | 3.2.186 \u8BE5 API \u4E0D resolve | \u81EA\u52A8\u964D\u7EA7\u6587\u672C\u6807\u7B7E + stub \u5BFC\u7EBF\uFF1B\u6279\u91CF\u7528 eda_place_net_labels |",
@@ -176,7 +178,24 @@ var SKILL_PLAYBOOK_LINES = [
176
178
  "- [ ] BOM\uFF1A\u4F4D\u53F7\u8FDE\u7EED\u3001\u5C01\u88C5\u975E\u7A7A\u3001\u65E0 DUMMY\u3002",
177
179
  "- [ ] \u89C6\u89C9\uFF1A\u5BFC\u51FA\u56FE\u7247\u81EA\u67E5\uFF0C\u6807\u7B7E\u4E0D\u538B\u7B26\u53F7\u3001\u5668\u4EF6\u4E0D\u91CD\u53E0\u3001\u529F\u80FD\u6846\u6E05\u6670\u3002",
178
180
  "- [ ] \u4FDD\u5B58\uFF1A\u6587\u6863\u5DF2\u4FDD\u5B58\uFF0C\u5DE5\u7A0B\u5DF2 eda_export_project \u5907\u4EFD\u3002",
179
- "- [ ] Gerber \u5DF2\u5BFC\u51FA\u4E14\u5C42\u6570\u4E0E\u677F\u5C42\u4E00\u81F4\u3002"
181
+ "- [ ] Gerber \u5DF2\u5BFC\u51FA\u4E14\u5C42\u6570\u4E0E\u677F\u5C42\u4E00\u81F4\u3002",
182
+ "",
183
+ "## 12. \u73AF\u5883\u4E0E\u751F\u6548\u89C4\u5219\uFF08\u5168\u65B0\u4F1A\u8BDD\u5FC5\u8BFB\uFF09",
184
+ "",
185
+ "\u8FD9\u5957\u5DE5\u5177\u5206\u4E09\u5C42\uFF0C\u6539\u52A8\u751F\u6548\u8303\u56F4\u5B8C\u5168\u4E0D\u540C\u2014\u2014\u4E0D\u786E\u5B9A\u65F6\u5148\u770B\u8FD9\u5F20\u8868\uFF0C\u4E0D\u8981\u51ED\u76F4\u89C9\u91CD\u8BD5\u3002",
186
+ "",
187
+ "| \u6539\u4E86\u4EC0\u4E48 | \u8C01\u6765\u505A | \u600E\u4E48\u751F\u6548 | \u600E\u4E48\u786E\u8BA4 |",
188
+ "|---|---|---|---|",
189
+ "| \u63D2\u4EF6\u6E90\u7801 `src/`\uFF08\u5BBF\u4E3B\u4FA7\uFF1A\u914D\u7F6E\u3001\u8DEF\u7531\u3001\u6280\u80FD\u6CE8\u518C\u3001MCP \u5DE5\u5177\u8868\uFF09 | \u5F00\u53D1\u8005 | `node build.mjs` \u540E**\u91CD\u542F DSH**\uFF08\u684C\u9762\u7AEF\u5173\u6389\u91CD\u5F00\uFF09 | \u4F1A\u8BDD\u91CC\u80FD\u770B\u5230\u65B0\u5DE5\u5177\uFF1B`/api/dsh-eda-mcp/diagnostics` \u7684 `skills` \u5B57\u6BB5 |",
190
+ "| \u6269\u5C55 `jlc-extension/`\uFF08\u767D\u540D\u5355\u3001\u5185\u90E8\u52A9\u624B\u3001\u7F16\u7801\u5904\u7406\uFF09 | \u7528\u6237 | \u5609\u7ACB\u521BEDA \u91CC**\u91CD\u65B0\u5BFC\u5165** `dsh-eda-bridge-extension-<\u7248\u672C>.zip` \u5E76**\u91CD\u542F\u5E94\u7528** | `eda_status` \u7684 `extensionVersions` \u4E0E `bridgeVersion` \u4E00\u81F4 |",
191
+ "| \u53EA\u9700\u91CD\u542F\u6865\u63A5\u5B50\u8FDB\u7A0B\uFF08\u6539\u4E86 `lib/bridge.mjs`\u3001\u6362\u7AEF\u53E3\u3001\u5361\u4F4F\uFF09 | agent \u53EF\u81EA\u884C | `POST /api/dsh-eda-mcp/reconnect` | \u8FD4\u56DE 200 \u4E14 `eda_status.connectedClients \u2265 1` |",
192
+ "| \u6CA1\u6539\u4EFB\u4F55\u4E1C\u897F\u4F46\u5DE5\u5177\u83AB\u540D\u6D88\u5931 | agent | \u5148 reconnect\uFF1B\u4ECD\u4E0D\u884C\u770B `/diagnostics` \u7684 `clientStartupError` | \u5DE5\u5177\u91CD\u65B0\u51FA\u73B0\u5728\u4F1A\u8BDD\u91CC |",
193
+ "",
194
+ "- **\u63D2\u4EF6\u4EE3\u7801\u6539\u52A8\u5BF9\u5F53\u524D\u4F1A\u8BDD\u5B8C\u5168\u4E0D\u751F\u6548**\uFF1A\u5BBF\u4E3B\u5728\u542F\u52A8\u65F6\u628A `lib/index.js` \u8F7D\u5165\u5185\u5B58\uFF0C\u4E4B\u540E\u6539\u6587\u4EF6\u3001\u91CD\u65B0 build \u90FD\u4E0D\u4F1A\u70ED\u66F4\u65B0\u3002",
195
+ "- \u7248\u672C\u7EAA\u5F8B\uFF1A\u6269\u5C55\u7EBF\u534F\u8BAE**\u53EA\u9012\u589E\u6700\u5C0F\u4F4D**\uFF08`0.4.1 \u2192 0.4.2`\uFF09\uFF0C\u6BCF\u6B21\u6539\u52A8\u5FC5\u987B\u540C\u65F6\u6539 `jlc-extension/extension.json` \u7684 `version` \u4E0E\u6865\u63A5\u7684 `VERSION`\uFF1B\u4E0D\u4E00\u81F4\u65F6 `eda_status.versionMismatch` \u4E3A true\u3002",
196
+ "- \u65B0\u4F1A\u8BDD\u5F00\u5DE5\u987A\u5E8F\uFF1A`eda_status`\uFF08\u8FDE\u6CA1\u8FDE\u3001\u7248\u672C\u5BF9\u4E0D\u5BF9\uFF09\u2192 \u672C\u6280\u80FD\uFF08\u600E\u4E48\u5E72\uFF09\u2192 \u518D\u52A8\u624B\uFF1B\u4E0D\u8981\u5148\u5047\u8BBE\u73AF\u5883\u5C31\u7EEA\u3002",
197
+ "- \u6269\u5C55 zip \u4F4D\u7F6E\uFF1A`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<\u7248\u672C>.zip`\uFF0C\u6216\u8BBE\u7F6E\u5361\u7247\u4E0B\u8F7D\uFF08\u6587\u4EF6\u540D\u81EA\u5E26\u7248\u672C\u53F7\uFF09\u3002",
198
+ "- \u6392\u67E5\u987A\u5E8F\uFF1A`eda_status` \u2192 `/api/dsh-eda-mcp/diagnostics`\uFF08`clientStartupError` / `skills` / `bridgeHealth`\uFF09\u2192 `eda_list_api_methods`\uFF08\u767D\u540D\u5355\uFF09\u2192 \u5E94\u7528\u4FA7 DRC \u9762\u677F\u3002"
180
199
  ];
181
200
  var SKILL_SECTIONS = SKILL_PLAYBOOK_LINES.filter((line) => line.startsWith("## "));
182
201
  var SKILL_PLAYBOOK = SKILL_PLAYBOOK_LINES.join(String.fromCharCode(10));
@@ -828,6 +847,12 @@ function describeBridgeLaunch() {
828
847
  return { command: runtime.command, args: [BRIDGE_PATH], source: runtime.source };
829
848
  }
830
849
  function apply(ctx, config) {
850
+ let skillState = {
851
+ attempted: false,
852
+ registered: false,
853
+ error: null,
854
+ names: []
855
+ };
831
856
  let current = () => unwrapConfig(config ?? {});
832
857
  let disposeClient;
833
858
  let disposeSection;
@@ -879,7 +904,9 @@ function apply(ctx, config) {
879
904
  extensionZipPath: resolveExtensionZipPath(),
880
905
  reconnect: remountClient,
881
906
  bridgeLaunch: describeBridgeLaunch,
882
- diagnostics: () => diagnostics.liveSnapshot(),
907
+ // Surface the skill registration so "did it register?" is answerable from
908
+ // /api/dsh-eda-mcp/diagnostics instead of guessing from the skill list.
909
+ diagnostics: () => ({ ...diagnostics.liveSnapshot(), skills: skillState }),
883
910
  launchConfig: () => bridgeLaunchFor(current()),
884
911
  selfTest: async () => {
885
912
  const runtime = resolveBridgeRuntime();
@@ -899,12 +926,24 @@ function apply(ctx, config) {
899
926
  }, "eda-mcp: settings bridge");
900
927
  });
901
928
  ctx.inject(["skills"], (sctx) => {
902
- const register = sctx.skills?.registerProvider;
903
- if (typeof register !== "function") return;
904
- const dispose = register(() => createEdaSkillProvider());
905
- return () => {
906
- dispose();
907
- };
929
+ sctx.effect(() => {
930
+ const skills = sctx.skills;
931
+ if (skills === void 0 || typeof skills.registerProvider !== "function") {
932
+ skillState = { ...skillState, attempted: true, registered: false, error: "the host exposes no skills service" };
933
+ return () => {
934
+ };
935
+ }
936
+ skillState = { ...skillState, attempted: true };
937
+ const dispose = skills.registerProvider(() => {
938
+ const provider = createEdaSkillProvider();
939
+ skillState = { ...skillState, registered: true, error: null, names: [EDA_SKILL_NAME] };
940
+ return provider;
941
+ });
942
+ return () => {
943
+ skillState = { ...skillState, registered: false };
944
+ dispose();
945
+ };
946
+ }, "eda-mcp: schematic skill");
908
947
  });
909
948
  const sync = () => {
910
949
  if (disposeSection !== void 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-eda-mcp",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
@@ -75,7 +75,8 @@
75
75
  "test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/skills.test.ts",
76
76
  "seam-check": "node scripts/seam-check.mjs",
77
77
  "contract-check": "node scripts/contract-check.mjs",
78
- "check": "pnpm typecheck && pnpm test && pnpm contract-check && pnpm build",
78
+ "guard:bundle": "node scripts/guard-bundle-paths.mjs",
79
+ "check": "pnpm typecheck && pnpm test && pnpm contract-check && pnpm build && pnpm guard:bundle",
79
80
  "docs": "node scripts/gen-tools-doc.mjs && node scripts/gen-skill-doc.mjs",
80
81
  "prepublishOnly": "pnpm check",
81
82
  "prepare": "pnpm build"
@@ -18,7 +18,7 @@ import {
18
18
  } from './design-rules.ts'
19
19
  import { WORKFLOW_GUIDE, WORKFLOW_SUMMARY } from './workflow-guide.ts'
20
20
 
21
- const VERSION = '0.4.0'
21
+ const VERSION = '0.4.2'
22
22
  const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
23
23
  const WS_HOST = '127.0.0.1'
24
24
  const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
@@ -58,9 +58,18 @@ function safeAreas(paperName) {
58
58
  function decodeTextPayload(value) {
59
59
  const text = typeof value === 'string' ? value : ''
60
60
  if (text === '') return text
61
- const looksUtf16 = text.charCodeAt(0) === 0xfeff || (text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0)
62
- if (!looksUtf16) return text
63
- return Buffer.from(text, 'latin1').toString('utf16le').replace(String.fromCharCode(0xfeff), '')
61
+ // Three shapes reach us: an already-decoded string with a U+FEFF prefix, the raw
62
+ // BOM bytes mangled into the characters 0xFF 0xFE, and a null-interleaved UTF-16
63
+ // body without a BOM. Measured on EDA 3.2.186 the CSV BOM arrives mangled, which
64
+ // the first version of this helper missed.
65
+ if (text.charCodeAt(0) === 0xfeff) return text.slice(1)
66
+ if (text.charCodeAt(0) === 0xff && text.charCodeAt(1) === 0xfe) {
67
+ return Buffer.from(text.slice(2), 'latin1').toString('utf16le')
68
+ }
69
+ if (text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0) {
70
+ return Buffer.from(text, 'latin1').toString('utf16le')
71
+ }
72
+ return text
64
73
  }
65
74
 
66
75
  function checkPointInFrame(x, y, paperName, margin = 0) {
@@ -296,8 +305,8 @@ const TOOLS = [
296
305
  { name: 'eda_open_document', description: 'Open/activate a document tab by uuid (dmt_EditorControl.openDocument). Works for a schematic page or a PCB, so tab-dependent APIs can run.', inputSchema: objectSchema({ documentUuid: { type: 'string' }, splitScreenId: { type: 'string' } }, ['documentUuid']) },
297
306
  { name: 'eda_get_net_labels', description: 'List every label-like primitive on the active schematic page: attribute primitives (netlabel/netport/netflag) PLUS plain texts left by the createNetLabel fallback, so they can be audited and deleted. Reports kind/primitiveId/net per entry.', inputSchema: objectSchema({ parentPrimitiveId: { type: 'string' } }) },
298
307
  { name: 'eda_place_net_label', description: 'Place a net label at x/y. The real API (sch_PrimitiveAttribute.createNetLabel) is probed once per session; when the build never answers, a plain TEXT label is placed instead, which carries no electrical meaning and must be paired with a stub wire. Pass probe:true to re-check.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, net: { type: 'string' }, fontSize: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] } }, ['x', 'y', 'net']) },
299
- { name: 'eda_place_net_labels', description: 'Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].', inputSchema: objectSchema({ items: { type: 'array', items: { type: 'object', additionalProperties: true } }, probe: { type: 'boolean' } }, ['items']) },
300
- { name: 'eda_run_drc', description: 'Run schematic DRC with includeVerboseError and return one entry per problem (type, message, primitiveId, x/y plus the raw record) instead of bare counts.', inputSchema: objectSchema({ strict: { type: 'boolean' } }) },
308
+ { name: 'eda_place_net_labels', description: 'Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].', inputSchema: objectSchema({ items: { type: 'array', items: { type: 'object', additionalProperties: true } }, probe: { type: 'boolean' }, concurrency: { type: 'integer', minimum: 1, maximum: 8 } }, ['items']) },
309
+ { name: 'eda_run_drc', description: 'Run schematic DRC with includeVerboseError. On EDA 3.2.186 the app still returns AGGREGATED entries ({type:"warn",count:N}) with no primitiveId/coordinates, so entries are reported as-is and raw keeps the untouched payload; expect counts plus a hint until the app exposes details.', inputSchema: objectSchema({ strict: { type: 'boolean' } }) },
301
310
  { name: 'eda_auto_layout', description: 'Run schematic auto-layout (sch_Document.autoLayout). DISABLED by default: automated layout is not used unless the user asks for it. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
302
311
  { name: 'eda_auto_route', description: 'Run schematic auto-routing (sch_Document.autoRouting). DISABLED by default: route by hand unless the user explicitly asks. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
303
312
  { name: 'eda_export_netlist', description: 'Export the schematic netlist as text (default JLCEDA format). Returns null when no schematic is active - check eda_get_active_document / open the schematic page first.', inputSchema: objectSchema({ type: { type: 'string' } }) },
@@ -1174,6 +1183,32 @@ function internalIdsOf(info) {
1174
1183
  return ids
1175
1184
  }
1176
1185
 
1186
+ /**
1187
+ * Refuse PCB writes on a net the board does not have.
1188
+ *
1189
+ * Measured on 3.2.186: pcb_PrimitiveLine.create happily accepts an arbitrary net
1190
+ * name and the board then carries a phantom net (which DRC reports as a netlist
1191
+ * error). force:true is the documented escape hatch.
1192
+ */
1193
+ async function assertKnownPcbNet(toolName, net, timeoutMs, force) {
1194
+ const name = String(net ?? '').trim()
1195
+ if (name === '') throw new Error(toolName + ' requires a net name')
1196
+ if (force === true) return
1197
+ let names
1198
+ try {
1199
+ const raw = await callExtension('pcb_Net.getAllNetsName', [], Math.min(timeoutMs, 8000))
1200
+ names = new Set((Array.isArray(raw) ? raw : []).map(value => String(value)))
1201
+ } catch {
1202
+ // The PCB tab may not be active yet; let the write itself decide.
1203
+ return
1204
+ }
1205
+ if (names.size === 0) return
1206
+ if (names.has(name)) return
1207
+ const sample = [...names].slice(0, 12).join(', ')
1208
+ throw new Error(toolName + ': net "' + name + '" is not on this PCB (' + names.size + ' nets: ' + sample +
1209
+ '…). A write on an unknown net creates a phantom net that DRC reports as a netlist error; pass force:true if that is really intended.')
1210
+ }
1211
+
1177
1212
  async function executeTool(name, rawArgs, timeoutMs) {
1178
1213
  const args = asRecord(rawArgs)
1179
1214
  const tool = PCB_TOOL_ALIASES[name] ?? name
@@ -1770,18 +1805,21 @@ async function executeTool(name, rawArgs, timeoutMs) {
1770
1805
  // caller's timeout with no way to learn what had already been placed.
1771
1806
  const items = Array.isArray(args.items) ? args.items.map(asRecord) : []
1772
1807
  if (items.length === 0) throw new Error('eda_place_net_labels requires items: [{ pinPrimitiveId, net }] or [{ x, y, net }]')
1773
- const results = []
1774
- for (const item of items) {
1808
+ // Measured on 3.2.186: one label costs ~2 s (the app writes per primitive), so a
1809
+ // sequential 133-label run still took minutes. Items are independent, so run them
1810
+ // with bounded concurrency; results keep the caller's order.
1811
+ const concurrency = Number.isFinite(Number(args.concurrency)) ? Math.max(1, Math.min(8, Number(args.concurrency))) : 4
1812
+ const results = await mapLimit(items, concurrency, async (item) => {
1775
1813
  const net = item.net === undefined ? '' : String(item.net)
1776
1814
  try {
1777
1815
  const placed = item.pinPrimitiveId !== undefined
1778
1816
  ? await placeAtPin('eda_place_net_label_at_pin', { ...item, net }, timeoutMs)
1779
1817
  : await placeNetLabel(net, Number(item.x), Number(item.y), item.fontSize, timeoutMs, { probe: args.probe === true })
1780
- results.push({ ok: true, net, ...placed })
1818
+ return { ok: true, net, ...placed }
1781
1819
  } catch (error) {
1782
- results.push({ ok: false, net, error: error instanceof Error ? error.message : String(error) })
1820
+ return { ok: false, net, error: error instanceof Error ? error.message : String(error) }
1783
1821
  }
1784
- }
1822
+ })
1785
1823
  const done = results.filter(entry => entry.ok).length
1786
1824
  return {
1787
1825
  total: items.length,
@@ -1845,10 +1883,45 @@ async function executeTool(name, rawArgs, timeoutMs) {
1845
1883
  return { document: info, documentTypeName: DOCUMENT_TYPES[type] ?? String(record.documentType ?? 'unknown'), documentType: record.documentType }
1846
1884
  }
1847
1885
 
1848
- case 'eda_open_document':
1849
- return await callExtension('dmt_EditorControl.openDocument', [
1850
- String(args.documentUuid), args.splitScreenId === undefined ? undefined : String(args.splitScreenId),
1886
+ case 'eda_open_document': {
1887
+ const documentUuid = String(args.documentUuid ?? '')
1888
+ if (documentUuid === '') throw new Error('documentUuid is required')
1889
+ const raw = await callExtension('dmt_EditorControl.openDocument', [
1890
+ documentUuid, args.splitScreenId === undefined ? undefined : String(args.splitScreenId),
1851
1891
  ], timeoutMs)
1892
+ // Measured on 3.2.186: from a freshly launched app sitting on HOME, openDocument
1893
+ // answers without creating a tab, so callers thought the page was active and every
1894
+ // tab-dependent call then returned empty. Verify instead of trusting it.
1895
+ await sleep(600)
1896
+ let tabIds = []
1897
+ try {
1898
+ const tree = asRecord(await callExtension('dmt_EditorControl.getSplitScreenTree', [], Math.min(timeoutMs, 4000)))
1899
+ tabIds = (Array.isArray(tree.tabs) ? tree.tabs : []).map(tab => String(pickValue(asRecord(tab), 'tabId') ?? ''))
1900
+ } catch { /* the tree is only a hint */ }
1901
+ let activeUuid = ''
1902
+ try {
1903
+ const info = asRecord(await callExtension('dmt_SelectControl.getCurrentDocumentInfo', [], Math.min(timeoutMs, 4000)))
1904
+ activeUuid = String(pickValue(info, 'uuid', 'Uuid') ?? '')
1905
+ } catch { /* ignore */ }
1906
+ // "opened" must mean "this document is now the active one": right after an app
1907
+ // restart the call answers success while the Start Page (tab_page1) stays active,
1908
+ // and a length-based guess reported opened:true for it.
1909
+ const activeIsTarget = activeUuid === documentUuid
1910
+ const tabOpened = activeIsTarget || tabIds.some(tab => tab.startsWith(documentUuid + '@'))
1911
+ return {
1912
+ ...(raw !== null && typeof raw === 'object' ? raw : { result: raw ?? null }),
1913
+ opened: tabOpened,
1914
+ activated: activeIsTarget,
1915
+ activeDocumentUuid: activeUuid || null,
1916
+ tabIds,
1917
+ hint: tabOpened
1918
+ ? null
1919
+ : 'The app accepted openDocument but the document is not active (activeDocumentUuid=' +
1920
+ (activeUuid || 'unknown') + ', tabs: ' + (tabIds.join(', ') || 'none') +
1921
+ '). After an app restart the editor sits on the Start Page: open the project first'
1922
+ + ' (eda_open_project), then open the document. This build cannot open a document from the HOME screen programmatically, so never assume a page is active without checking activeDocumentUuid.',
1923
+ }
1924
+ }
1852
1925
 
1853
1926
  case 'eda_set_no_connect': {
1854
1927
  const ids = Array.isArray(args.primitiveIds) ? args.primitiveIds.map(String) : []
@@ -2019,7 +2092,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2019
2092
  raw: entry,
2020
2093
  }
2021
2094
  }),
2022
- hint: 'Field names vary by EDA build; "raw" keeps the untouched record.',
2095
+ hint: 'EDA 3.2.186 returns AGGREGATED entries only (e.g. {type:"warn",count:1}) even with includeVerboseError, so message/primitiveId stay null — "raw" carries exactly what the app sent. Per-problem coordinates are not available on this build; use eda_pcb_run_drc for the PCB side and the DRC panel for details.',
2023
2096
  }
2024
2097
  }
2025
2098
  return { ok: Boolean(verbose), count: null, entries: [], raw: verbose }
@@ -2069,8 +2142,17 @@ async function executeTool(name, rawArgs, timeoutMs) {
2069
2142
  const raw = typeof payload === 'string'
2070
2143
  ? payload
2071
2144
  : (payload !== null && typeof payload === 'object' && typeof payload.text === 'string' ? payload.text : '')
2072
- // CSV arrives as UTF-16LE bytes; returned verbatim it read as NUL-separated junk.
2073
- return { format, bom: decodeTextPayload(raw) }
2145
+ // Measured: with the PCB active the export answers null and this used to return
2146
+ // { bom: '', encoding: 'unknown' }, which reads like an empty BOM instead of the
2147
+ // real cause. Say it plainly, like eda_export_netlist does.
2148
+ if (raw === '') {
2149
+ throw new Error('eda_export_bom: no BOM data (the schematic page must be the active document; ' +
2150
+ 'call eda_get_active_document, then eda_open_document on the schematic page and retry)')
2151
+ }
2152
+ // The extension (0.4.1+) decodes text payloads with the encoding named by their BOM;
2153
+ // decodeTextPayload stays as a fallback for older extensions.
2154
+ const encoding = payload !== null && typeof payload === 'object' ? payload.encoding : undefined
2155
+ return { format, bom: decodeTextPayload(raw), encoding: encoding ?? 'unknown' }
2074
2156
  }
2075
2157
 
2076
2158
  case 'eda_delete_primitive': {
@@ -2175,6 +2257,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2175
2257
  if (layer === undefined) throw new Error('pcb_draw_track requires a valid layer')
2176
2258
  const net = String(args.net)
2177
2259
  assertNetName(net, 'pcb_draw_track')
2260
+ await assertKnownPcbNet('pcb_draw_track', net, timeoutMs, args.force === true)
2178
2261
  // Width defaults to the role-based suggestion and is checked against the
2179
2262
  // 0.15 mm design minimum (docs/design-rules.md §5.1); force overrides.
2180
2263
  const width = checkTrackWidth(net, args.lineWidth === undefined ? undefined : Number(args.lineWidth), args.force === true)
@@ -2189,6 +2272,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
2189
2272
  case 'pcb_draw_via': {
2190
2273
  const net = String(args.net)
2191
2274
  assertNetName(net, 'pcb_draw_via')
2275
+ await assertKnownPcbNet('pcb_draw_via', net, timeoutMs, args.force === true)
2192
2276
  // Defaults follow the standard drill pairs; geometry is checked in mm.
2193
2277
  const role = classifyNet(net)
2194
2278
  const defaultVia = role === 'signal' ? VIA_SIZES_MM[0] : VIA_SIZES_MM[1]
package/src/guidance.ts CHANGED
@@ -21,6 +21,7 @@ export const EDA_MCP_GUIDANCE = [
21
21
  '绘制细节:原理图坐标为 0.01inch,原点在 A4 图框左下角,y 向上,图框 x 0~1170、y 0~825,标题栏 x 460~1160、y 10~190 必须避让(先 eda_get_page_info)。PCB 坐标为 mil。板框用 pcb_draw_board_outline(板框层 11 的一条 pcb_PrimitivePolyline,线宽默认 10mil);区域只能用铜层 top/bottom/内层(15–44)/MULTI(12)。',
22
22
  '长任务:eda_pcb_auto_route 默认异步启动(EDA 自己显示进度条),必须用 eda_pcb_job_status 轮询 running 直到 false,不要加大超时;eda_pcb_clear_routing 同理(旧扩展自动分批,按 done 继续调用)。',
23
23
  '原理图 API(sch_Net.*、网表导出)只作用于当前活动文档:返回空时先用 eda_get_active_document 查、eda_open_document 切到原理图页。',
24
+ '生效范围(改动不热更新):插件源码 src/ 改动要 node build.mjs 后重启 DSH,当前会话不会生效;扩展 jlc-extension/ 改动要重新导入 dsh-eda-bridge-extension-<版本>.zip 并重启嘉立创EDA(eda_status 的 extensionVersions 必须与 bridgeVersion 一致);只有重启桥接子进程能由 agent 自己做(POST /api/dsh-eda-mcp/reconnect)。排查出口:/api/dsh-eda-mcp/diagnostics 的 clientStartupError / skills / bridgeHealth。扩展版本只递增最小位。',
24
25
  '原理图工作规范(默认手工,禁止为省事用自动布局/自动布线):按 10 单位栅格对齐;信号左→右、电源在上、地在下;器件间留 ≥20 单位走线与标签空间;先 eda_get_page_info 看安全区再摆位;摆完用 eda_get_components 检查包围盒不重叠。eda_auto_layout / eda_auto_route 默认被拒绝(需 force:true 且设置里放行),仅当用户明确要求时使用;版面检查优先"导出图片自行识别"而不是让 EDA 重排。',
25
26
  '跨区域同名网络必须用 eda_place_net_port / 网络标签:eda_draw_wire 的 net 只对悬空导线有效,导线一旦接触引脚,EDA 会按连通性重算网络名(实测 TESTNET 被并入 LED_A),所以"同名导线 = 同名网络"不成立。',
26
27
  '网络标签:eda_place_net_label 先真实调 createNetLabel,该 API 在 3.2.186 上不响应 → 探测结论按会话缓存后自动降级为纯文本标签(无电气语义,靠 stub 导线承载网络);批量务必用 eda_place_net_labels 一次调用,不要串行上百次(会打爆 run_code 超时且没有进度)。标签可枚举/清理:eda_get_net_labels + eda_delete_primitive(kind: netlabel|netport|netflag|text)。',
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { existsSync, readFileSync } from 'node:fs'
9
9
  import { dirname } from 'node:path'
10
10
  import { EDA_MCP_GUIDANCE, SECTION_ORDER } from './guidance.ts'
11
- import { createEdaSkillProvider } from './skills.ts'
11
+ import { createEdaSkillProvider, EDA_SKILL_NAME } from './skills.ts'
12
12
  import { makeEdaMcpRoutes, makeEdaMcpSettingsRoutes, type SettingsBridge } from './routes.ts'
13
13
  import { resolveBridgeRuntime } from './node-runtime.ts'
14
14
  import { BridgeDiagnostics } from './bridge-diagnostics.ts'
@@ -226,7 +226,15 @@ function describeBridgeLaunch(): { command: string; args: string[]; source: stri
226
226
  export function apply(ctx: Context, config?: EdaMcpConfig): void {
227
227
  // Config values always reach the rest of the plugin unwrapped: on DSH >= 0.2
228
228
  // `volatile` fields arrive as live value handles.
229
- let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
229
+ /** Skill registration outcome, reported by /api/dsh-eda-mcp/diagnostics. */
230
+ let skillState: { attempted: boolean; registered: boolean; error: string | null; names: string[] } = {
231
+ attempted: false,
232
+ registered: false,
233
+ error: null,
234
+ names: [],
235
+ }
236
+
237
+ let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
230
238
  let disposeClient: (() => Promise<void>) | undefined
231
239
  let disposeSection: (() => void) | undefined
232
240
  let mounting: Promise<void> = Promise.resolve()
@@ -286,7 +294,9 @@ export function apply(ctx: Context, config?: EdaMcpConfig): void {
286
294
  extensionZipPath: resolveExtensionZipPath(),
287
295
  reconnect: remountClient,
288
296
  bridgeLaunch: describeBridgeLaunch,
289
- diagnostics: () => diagnostics.liveSnapshot(),
297
+ // Surface the skill registration so "did it register?" is answerable from
298
+ // /api/dsh-eda-mcp/diagnostics instead of guessing from the skill list.
299
+ diagnostics: () => ({ ...diagnostics.liveSnapshot(), skills: skillState }),
290
300
  launchConfig: () => bridgeLaunchFor(current()),
291
301
  selfTest: async () => {
292
302
  const runtime = resolveBridgeRuntime()
@@ -307,12 +317,27 @@ export function apply(ctx: Context, config?: EdaMcpConfig): void {
307
317
  // catalog next to local/project skills and loads on demand with the `skill` tool.
308
318
  // Structural typing + a runtime guard keeps older hosts (no skills service) working.
309
319
  ctx.inject(['skills'], (sctx) => {
310
- const register = (sctx as unknown as {
311
- skills?: { registerProvider?: (factory: (control: unknown) => unknown) => () => void }
312
- }).skills?.registerProvider
313
- if (typeof register !== 'function') return
314
- const dispose = register(() => createEdaSkillProvider())
315
- return () => { dispose() }
320
+ // Mirrors the reference implementation (dsh matlab-mcp plugin): the registration is
321
+ // an effect, so it is torn down with the plugin instead of leaking a provider.
322
+ sctx.effect(() => {
323
+ const skills = (sctx as unknown as {
324
+ skills?: { registerProvider?: (factory: (control: unknown) => unknown) => () => void }
325
+ }).skills
326
+ if (skills === undefined || typeof skills.registerProvider !== 'function') {
327
+ skillState = { ...skillState, attempted: true, registered: false, error: 'the host exposes no skills service' }
328
+ return () => { /* nothing to undo */ }
329
+ }
330
+ skillState = { ...skillState, attempted: true }
331
+ const dispose = skills.registerProvider(() => {
332
+ const provider = createEdaSkillProvider()
333
+ skillState = { ...skillState, registered: true, error: null, names: [EDA_SKILL_NAME] }
334
+ return provider
335
+ })
336
+ return () => {
337
+ skillState = { ...skillState, registered: false }
338
+ dispose()
339
+ }
340
+ }, 'eda-mcp: schematic skill')
316
341
  })
317
342
 
318
343
  const sync = (): void => {
@@ -117,6 +117,7 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
117
117
  "| connectedClients=0 / 报未连接 | EDA 没开、扩展没导入或没点「连接」、端口被占 | 先 eda_status 看 hint;必要时 eda_launch 起应用并等扩展连上 |",
118
118
  "| extensionVersions 与 bridgeVersion 不一致 | 装的是旧扩展 | 重新导入对应版本 zip(文件名带版本号)并重启应用 |",
119
119
  "| API method not allowed | 该 API 不在扩展白名单 | 调 eda_list_api_methods 看清单;优先用包装好的工具 |",
120
+ "| open_document 返回 opened:false / activated:false | 应用停在 Start Page(tab_page1),该构建无法从 HOME 程序化打开文档 | 先 eda_open_project 打开工程,再 eda_open_document;用 activeDocumentUuid 确认,别假设已激活 |",
120
121
  "| 调用 60s 超时 | 页面切走留下僵尸 socket,或单次改太多 | 只读调用会自动换页面重试一次;把批量拆到 ≤20/批 |",
121
122
  "| 读图返回空 / null | 活动文档不是原理图页 | eda_get_active_document → eda_open_document 切页 |",
122
123
  "| createNetLabel 无响应 | 3.2.186 该 API 不 resolve | 自动降级文本标签 + stub 导线;批量用 eda_place_net_labels |",
@@ -157,6 +158,23 @@ export const SKILL_PLAYBOOK_LINES: readonly string[] = [
157
158
  "- [ ] 视觉:导出图片自查,标签不压符号、器件不重叠、功能框清晰。",
158
159
  "- [ ] 保存:文档已保存,工程已 eda_export_project 备份。",
159
160
  "- [ ] Gerber 已导出且层数与板层一致。",
161
+ "",
162
+ "## 12. 环境与生效规则(全新会话必读)",
163
+ "",
164
+ "这套工具分三层,改动生效范围完全不同——不确定时先看这张表,不要凭直觉重试。",
165
+ "",
166
+ "| 改了什么 | 谁来做 | 怎么生效 | 怎么确认 |",
167
+ "|---|---|---|---|",
168
+ "| 插件源码 `src/`(宿主侧:配置、路由、技能注册、MCP 工具表) | 开发者 | `node build.mjs` 后**重启 DSH**(桌面端关掉重开) | 会话里能看到新工具;`/api/dsh-eda-mcp/diagnostics` 的 `skills` 字段 |",
169
+ "| 扩展 `jlc-extension/`(白名单、内部助手、编码处理) | 用户 | 嘉立创EDA 里**重新导入** `dsh-eda-bridge-extension-<版本>.zip` 并**重启应用** | `eda_status` 的 `extensionVersions` 与 `bridgeVersion` 一致 |",
170
+ "| 只需重启桥接子进程(改了 `lib/bridge.mjs`、换端口、卡住) | agent 可自行 | `POST /api/dsh-eda-mcp/reconnect` | 返回 200 且 `eda_status.connectedClients ≥ 1` |",
171
+ "| 没改任何东西但工具莫名消失 | agent | 先 reconnect;仍不行看 `/diagnostics` 的 `clientStartupError` | 工具重新出现在会话里 |",
172
+ "",
173
+ "- **插件代码改动对当前会话完全不生效**:宿主在启动时把 `lib/index.js` 载入内存,之后改文件、重新 build 都不会热更新。",
174
+ "- 版本纪律:扩展线协议**只递增最小位**(`0.4.1 → 0.4.2`),每次改动必须同时改 `jlc-extension/extension.json` 的 `version` 与桥接的 `VERSION`;不一致时 `eda_status.versionMismatch` 为 true。",
175
+ "- 新会话开工顺序:`eda_status`(连没连、版本对不对)→ 本技能(怎么干)→ 再动手;不要先假设环境就绪。",
176
+ "- 扩展 zip 位置:`node_modules/dsh-eda-mcp/dsh-eda-bridge-extension-<版本>.zip`,或设置卡片下载(文件名自带版本号)。",
177
+ "- 排查顺序:`eda_status` → `/api/dsh-eda-mcp/diagnostics`(`clientStartupError` / `skills` / `bridgeHealth`)→ `eda_list_api_methods`(白名单)→ 应用侧 DRC 面板。",
160
178
  ]
161
179
 
162
180
  export const SKILL_SECTIONS: readonly string[] = SKILL_PLAYBOOK_LINES.filter(line => line.startsWith("## "))
@@ -142,3 +142,19 @@ for (const name of ['@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client
142
142
 
143
143
  console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
144
144
  process.exit(failures === 0 ? 0 : 1)
145
+
146
+ // --- CI workflows must reference artefacts the build actually produces -------
147
+ // Two released pipelines broke on this exact class of drift: the bundle-path guard
148
+ // flagged our own documentation string, and check.yml uploaded an unversioned zip the
149
+ // build had stopped producing. Both are asserted here instead of being rediscovered.
150
+ {
151
+ const check = readFileSync(join(root, '.github/workflows/check.yml'), 'utf8')
152
+ const publish = readFileSync(join(root, '.github/workflows/publish.yml'), 'utf8')
153
+ report('check.yml uploads the versioned zip glob', check.includes('dsh-eda-bridge-extension-*.zip'))
154
+ report('check.yml runs the shared bundle guard', check.includes('pnpm guard:bundle'))
155
+ report('publish.yml runs the shared bundle guard', publish.includes('pnpm guard:bundle'))
156
+ report('no workflow re-implements the path guard inline', !/Select-String[\s\S]{0,200}node_modules/.test(check + publish))
157
+ const pkgJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
158
+ report('guard:bundle is wired into the scripts', typeof pkgJson.scripts?.['guard:bundle'] === 'string')
159
+ report('pnpm check runs the bundle guard', String(pkgJson.scripts?.check ?? '').includes('guard:bundle'))
160
+ }
@@ -26,7 +26,7 @@ report('skill is model and user invocable', candidate.invocation.modelInvocable
26
26
  report('description states a trigger', candidate.description.includes('TRIGGER when'))
27
27
  report('description states a non-trigger', candidate.description.includes('DO NOT TRIGGER'))
28
28
  report('rank beats the user root (400)', candidate.rank > 400, String(candidate.rank))
29
- report('playbook has all twelve sections', SKILL_SECTIONS.length === 12, String(SKILL_SECTIONS.length))
29
+ report('playbook has all thirteen sections', SKILL_SECTIONS.length === 13, String(SKILL_SECTIONS.length))
30
30
  report('playbook is a real manual, not a summary', body.length > 6000 && skillLineCount() > 120, body.length + ' chars / ' + skillLineCount() + ' lines')
31
31
 
32
32
  const definition = await provider.get(candidate)
@@ -51,6 +51,12 @@ const required = [
51
51
  'eda_get_net_labels',
52
52
  'eda_list_api_methods',
53
53
  '| 现象 | 原因 | 动作 |',
54
+ '## 12. 环境与生效规则(全新会话必读)',
55
+ '重启 DSH',
56
+ '重新导入',
57
+ '/api/dsh-eda-mcp/diagnostics',
58
+ 'activated:false',
59
+ '只递增最小位',
54
60
  ]
55
61
  for (const token of required) {
56
62
  report('manual mentions ' + token, body.includes(token))
Binary file