dsh-plc-decode 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,7 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-plc-decode
58
58
  | 命令 | 用途 |
59
59
  |---|---|
60
60
  | `/plc-decode <文件> [输出目录]` | 统一解码入口 |
61
+ | `/plc-archive` | 查看当前生效的解码归档根目录、默认落点与改配置的方法 |
61
62
  | `/codesys-read <工程>` | 解码 `.project`(ST 源码 + 报告)或 `.dvp`(解码报告) |
62
63
  | `/dvp-read <工程>` | 台达 DVP 结构与指令表报告 |
63
64
  | `/smart-read <.smart/.smartlib> [输出目录]` | SMART 工程 / 库解码 |
@@ -79,6 +80,26 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-plc-decode
79
80
  | `.dvp` | `00-索引.md`、`解码报告.md`(结构 / 段落注释 / 设备注释 / 指令表)、可选 `程序解读.md` |
80
81
  | `.smart` / `.smartlib` | `00-目录.md`(先读这份)、`解码报告.md`、`程序.lad`、`元素记录.tsv`、`符号地址.tsv`、`分析.md`、`记录.json`、`字符串全录.txt` |
81
82
 
83
+ ### 改归档地址(不用改代码)
84
+
85
+ 在 DSH profile 的 `cordis.patch.yml`(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)末尾加这几行:
86
+
87
+ ```yaml
88
+ - id: dsh-plc-decode
89
+ name: dsh-plc-decode
90
+ config:
91
+ archiveDir: D:/plc/解码归档
92
+ ```
93
+
94
+ `archiveDir` 是归档**根目录**:相对路径按当前会话工作区解析,绝对路径原样使用;默认 `解码归档`。改完重启 dsh 生效,`/plc-archive` 可随时查看当前生效值;只想改一次就在解码时传 `outDir`。
95
+
96
+ 实测同一份 `Dvp0.dvp` 在两种配置下的落点:
97
+
98
+ | cordis.patch.yml | 解码落点 |
99
+ |---|---|
100
+ | 不写 `config` | `<工作区>\解码归档\靖昇20250212ZH1035\` |
101
+ | `archiveDir: D:/plc/解码归档` | `D:\plc\解码归档\靖昇20250212ZH1035\` |
102
+
82
103
  `.smart` 的 `记录.json` 同时是各分析工具(地址占用、变量使用等)的输入,也是识别「已解码目录」的凭据;解码报告头部带**源文件指纹**(文件大小 + 修改时间),工程换版本后先核对指纹再用旧报告。
83
104
 
84
105
  ## 已知边界
@@ -93,7 +114,7 @@ dsh plugin --profile web add github:taoziliyouchong/dsh-plc-decode
93
114
 
94
115
  ```sh
95
116
  npm run check # 语法检查
96
- npm run smoke # 装载插件 + 核对注册面(工具 12 个 / 命令 11 个)
117
+ npm run smoke # 装载插件 + 核对注册面(工具 12 个 / 命令 12 个)
97
118
  npm run smoke -- <工程文件> # 额外对一个真实工程跑端到端解码,核对产物落盘
98
119
  npm pack --dry-run # 看包里内容(node_modules、publish/ 不应进包)
99
120
  ```
@@ -109,6 +109,11 @@ COMPACT_TYPES = {0x05: "M", 0x09: "X", 0x0A: "Y",
109
109
  # 行号索引前缀(保留吞 5B 语义):14/16 为行号标记
110
110
  ROWIDX_PREFIX = {0x14, 0x16}
111
111
 
112
+ # 2026-11-05:认不出的记录台账。disasm 每跳一条未识别记录就往这里记一笔
113
+ # (段号, 行号, 字节原文),main 末尾聚合成「未识别记录」一节。
114
+ # 这不是猜语义,是把「读不出来」变成看得见的账——用户能据此对照 WPLSoft 指令表。
115
+ UNKNOWN_RECS = []
116
+
112
117
  # 触点/线圈指令前缀:
113
118
  # 12 触点族(LD/AND/T 页)、32=OUT、22=AND/ANI
114
119
  # B 系(B4/B6/B2/B9)= ANDP(上升沿,置位侧)
@@ -126,8 +131,11 @@ COMPACT_NAMES = {
126
131
  (0x16, 0x05): "LD", (0x16, 0x0A): "LD", (0x16, 0x19): "LD",
127
132
  }
128
133
  # B/C 系沿触点:B=ANDP、C=ANDF,全类型页
134
+ # 2026-11-05:B 系补 0x07(T 页)/ 0x1A(Y 页)——C 系早就有 0x1A 的实证注释,
135
+ # B 系漏页会让上升沿触点整条消失;本机 16 份工程实测 B 系出现类型页 {05,07,09,15,17,19,1A},
136
+ # 其中 0x17 页语义未定,暂不映射(宁缺不猜),其余补齐。
129
137
  for _p in (0xB4, 0xB6, 0xB2, 0xB9):
130
- for _t in (0x05, 0x09, 0x0A, 0x15, 0x19):
138
+ for _t in (0x05, 0x07, 0x09, 0x0A, 0x15, 0x19, 0x1A):
131
139
  COMPACT_NAMES[(_p, _t)] = "ANDP"
132
140
  for _p in (0xC6, 0xC9, 0xC2, 0xC4):
133
141
  for _t in (0x05, 0x09, 0x0A, 0x15, 0x19, 0x1A):
@@ -320,7 +328,7 @@ def _to_loading(cp):
320
328
  return cp
321
329
 
322
330
 
323
- def disasm(seg):
331
+ def disasm(seg, segno=0):
324
332
  """段数据区 → 行分组指令 token 流
325
333
  - 比较指令族 ?CMP(e2/e4/e6/e9 + 双操作数,指令名待对照,统一 ? 前缀)
326
334
  - 行尾/分隔记录整体吞 5B(0b/61 10/68 10/57 10/5a/34/39)
@@ -358,12 +366,11 @@ def disasm(seg):
358
366
  elif b == 0x0b:
359
367
  # 0b 00 00 00 00 = ORB(与指令表逐条对得上;紧随其后的全零记录是 ANB)
360
368
  toks.append('ORB'); i += 5
361
- elif b == 0x56:
362
- toks.append("MPS"); i += 1
363
- elif b == 0x59:
364
- toks.append("MRD"); i += 1
365
- elif b == 0x54:
366
- toks.append("MPP"); i += 1
369
+ elif b in (0x56, 0x59, 0x54):
370
+ # 2026-11-05:栈指令记录是完整 5 字节(实测 `56 10 00 00 00` / `59 00 00 00 00`
371
+ # / `54 10 00 00 00`),旧版 i += 1 把后 4 字节留进主循环:第 2 字节 0x10
372
+ # 会被当作未知记录,尾字节不是 0x00 时只跳 1 字节 → 后续记录整体错位。
373
+ toks.append({0x56: "MPS", 0x59: "MRD", 0x54: "MPP"}[b]); i += 5
367
374
  elif b == 0x90:
368
375
  # 0x90 记录携带 32 位数据的**高位字**(rec[4]=0x10 表示后面还有操作数)。
369
376
  # 高位字要并进前一个常数(如 K34463 + H1 = K99999),
@@ -371,16 +378,28 @@ def disasm(seg):
371
378
  hi = rec[2] | (rec[3] << 8)
372
379
  m = re.match(r'^([KH])([0-9A-F]+)$', toks[-1]) if toks else None
373
380
  if hi and m:
381
+ kind, digits = m.group(1), m.group(2)
374
382
  try:
375
- toks[-1] = f"{m.group(1)}{int(m.group(2)) + (hi << 16)}"
383
+ # 2026-11-05:①H 型常数内部存的是十六进制,旧版按十进制 int() 解析
384
+ # 要么 ValueError(整条不拼)要么算错;②K 是 32 位有符号——高半字
385
+ # 全 1 时是负数,旧版无符号拼接把 K-200000 显示成 K4294767296。
386
+ val = int(digits, 16 if kind == 'H' else 10) + (hi << 16)
387
+ if kind == 'K' and val >= 0x80000000:
388
+ val -= 0x100000000
389
+ toks[-1] = f"H{format(val, 'X')}" if kind == 'H' else f"K{val}"
376
390
  i += 5
377
391
  continue
378
392
  except ValueError:
379
393
  pass
380
394
  toks.append(f"|H{hi}|" if hi else "|")
381
395
  i += 5
382
- elif b == 0x42:
383
- if rec[2] == 0x18:
396
+ elif b in (0x42, 0x44, 0x49):
397
+ # 2026-11-05:0x44 / 0x49 是与 0x42 同形的功能指令记录——同为 5 字节,
398
+ # rec[2] 是功能码,直接查同一张 CMD_MAP/GUESS_CMD(实证 9 条全部对上:
399
+ # 44 10 5D 01 10 → 码 5D = DDRVI;44 10 00 D0 00 / 49 00 00 D0 00 → 码 00 = SET;
400
+ # 49 00 29 00 10 → 码 29 = DMOV)。
401
+ # 旧版不认这两种,尾字节 0x10 的记录落进 else 逐字节跳 → 整条指令名丢失且错位。
402
+ if b == 0x42 and rec[2] == 0x18:
384
403
  # 段首非程序记录(不在任何网络里、无操作数),不是指令,整体吞 5B
385
404
  i += 5
386
405
  continue
@@ -420,7 +439,12 @@ def disasm(seg):
420
439
  # 未识别记录若是标准 5 字节外形(尾字节 00),整吞,
421
440
  # 不再逐字节跳:逐字节跳会让**后续所有记录整体错位**,
422
441
  # 是指令序列与软件对不上的主要来源。
423
- i += 5 if rec[4] == 0x00 else 1
442
+ # 2026-11-05:同时记进台账 + 行内留痕,不再静默消失。
443
+ step = 5 if rec[4] == 0x00 else 1
444
+ UNKNOWN_RECS.append((segno, li, rec[:step].hex(' ')))
445
+ if step == 5:
446
+ toks.append(f"〔?{rec[0]:02x}{rec[1]:02x}{rec[2]:02x}〕")
447
+ i += step
424
448
  rows.append(" ".join(toks))
425
449
  return rows
426
450
 
@@ -436,6 +460,7 @@ def main():
436
460
 
437
461
  data = read_bytes(path)
438
462
  text = data.decode("ascii", "replace")
463
+ UNKNOWN_RECS.clear()
439
464
  lines = []
440
465
 
441
466
  # ---- 1. 结构 ----
@@ -514,11 +539,30 @@ def main():
514
539
  spans.append((0, de0 + len("<DataEnd>"), nxt))
515
540
  lines.append(" (本工程无 <SMT> 分段标记,按 Initial Value 块后的整块数据反汇编)")
516
541
  for si, ds, de in spans:
517
- rows = disasm(data[ds:de])
542
+ rows = disasm(data[ds:de], si)
518
543
  lines.append(f"--- 段{si} ---")
519
544
  for li, r in enumerate(rows):
520
545
  lines.append(f" 行{li}: {r}")
521
546
 
547
+ # ---- 4.5 未识别记录台账 ----
548
+ lines.append("")
549
+ lines.append("=" * 60)
550
+ lines.append("【未识别记录(认不出→已跳过;指令表里缺的东西在这里查)】")
551
+ lines.append("=" * 60)
552
+ if not UNKNOWN_RECS:
553
+ lines.append(" 无:本次解码的 5 字节记录全部落进已知指令/触点/操作数/填充。")
554
+ else:
555
+ agg = {}
556
+ for sg, li, hx in UNKNOWN_RECS:
557
+ agg.setdefault(hx, []).append((sg, li))
558
+ lines.append(f" 共 {len(UNKNOWN_RECS)} 条({len(agg)} 种字节形态):")
559
+ for hx, pos in sorted(agg.items(), key=lambda kv: -len(kv[1])):
560
+ where = "、".join(f"段{a}行{b}" for a, b in pos[:6])
561
+ more = f" 等 {len(pos)} 处" if len(pos) > 6 else ""
562
+ lines.append(f" ×{len(pos):<4} {hx:<15} 首见 {where}{more}")
563
+ lines.append(" 这些字节没被认成指令或触点,对应行里可能少了一条——拿字节形态去 WPLSoft")
564
+ lines.append(" 指令表对照;核出来再补 COMPACT_NAMES / CMD_MAP,别按猜测直接补。")
565
+
522
566
  # ---- 5. 推断对照表 ----
523
567
  lines.append("")
524
568
  lines.append("=" * 60)
@@ -75,7 +75,7 @@ export function writeSourcePack({ outd, kind, projName, projFile }) {
75
75
  nav += `\n## 怎么查(按问题挑一条,别整份读)\n\n`
76
76
  nav += `- 某段指令表 → 按下文「网络行号表」取段(行号=解码报告里的行,从 1 起)\n`
77
77
  nav += `- 某地址在哪些网络出现 → grep 地址(如 \`grep -n "D2340" 解码报告.md\`),只读命中的那几行\n`
78
- nav += `- 找中文注释/设备名/画面文本 → grep 全部字符串.txt\n\n`
78
+ nav += `- 找中文注释/设备名 → grep 解码报告.md(dvp 只出这一份文本,不生成 全部字符串.txt)\n\n`
79
79
  nav += `## 读法\n\n- **看程序**:台达 DVP 没有 pous/ST,程序就是 \`解码报告.md\` 的指令表(按 \`LD M1000\` 分网络,末尾 END)。\n- **AI**:目录里有 \`程序解读.md\` 就先读它(网络级功能地图+I/O 表+机制链+未确证清单),再回指令表逐段核对。报告里 \`?名\`(如 \`?DRVI\`)与 \`?CMP(关系)\` 是**推断**指令名——报告末尾有「推断对照表」(码/推测名/依据/把握),拿它下结论前先核台达指令表;\`??xx\` 与 \`?CMP[六位]\` 是推断不出、保持未定的。\n- 读之前核对源工程文件修改时间是否与本次解码一致(工程一改,导出即过期)。\n`
80
80
 
81
81
  // 网络行号表:让 AI 能按行号取一段,而不是整份读报告
package/index.js CHANGED
@@ -20,13 +20,29 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSy
20
20
  import { createHash } from 'node:crypto'
21
21
  import { inflateSync } from 'node:zlib'
22
22
  import { tmpdir } from 'node:os'
23
- import { basename, dirname, extname, join, resolve } from 'node:path'
23
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
24
24
  import { fileURLToPath } from 'node:url'
25
+ import z from '@deepseek-ai/schemastery'
25
26
  import { writeSmartPack, writeSourcePack } from './assets/decode-pack.mjs'
26
27
 
27
28
  export const name = 'dsh-plc-decode'
28
29
  export const inject = ['tools', 'commands', 'shell', 'sandboxPolicy']
29
30
 
31
+ /** 插件配置(DSH 官方 Config 机制:cordis.patch.yml 里本插件行的 config 传进 apply)。
32
+ * 2026-11-05 加:让装插件的人不改代码就能换解码产物的存放位置。 */
33
+ export const Config = z.object({
34
+ archiveDir: z.string().default('解码归档')
35
+ .description('解码产物归档根目录。相对路径按当前会话工作区解析,绝对路径原样使用。默认「解码归档」。'),
36
+ })
37
+
38
+ /** 生效中的归档根目录;apply 时由 config.archiveDir 覆盖,未配置即默认值(与旧版行为一致)。 */
39
+ let ARCHIVE_ROOT = '解码归档'
40
+
41
+ /** 归档根目录的绝对路径:绝对路径原样用,相对路径拼会话工作区。 */
42
+ function archiveRootPath(base) {
43
+ return isAbsolute(ARCHIVE_ROOT) ? ARCHIVE_ROOT : join(base || process.cwd(), ARCHIVE_ROOT)
44
+ }
45
+
30
46
  const PY = 'python'
31
47
  const TIMEOUT_MS = 300_000
32
48
  /** 本插件自带的资源目录(smart 连线/网表脚本等) */
@@ -72,7 +88,7 @@ function defaultArchiveDir(file, base) {
72
88
  const siblings = readdirSync(dir).filter((n) => /\.(smart|smartlib|dvp|project)$/i.test(n))
73
89
  if (siblings.length > 1) name = `${folder}_${stem}`
74
90
  } catch { /* 读不到目录就退回文件名 */ }
75
- return join(base || process.cwd(), '解码归档', name)
91
+ return join(archiveRootPath(base), name)
76
92
  }
77
93
 
78
94
  /** 把命令名后的原始输入按 shell 引号规则切成参数(支持带空格路径)。 */
@@ -105,7 +121,7 @@ async function shellResult(ctx, spec) {
105
121
 
106
122
  /** 通过 ctx.shell 前台运行一个 Python 脚本(继承会话沙箱策略 + WinError 5 重试一次)。 */
107
123
  async function runPy(ctx, script, args, workdir, signal, exec) {
108
- const cmd = `& ${PY} ${psq(join(CODESYS_TOOLS_DIR, script))} ${args.map(psq).join(' ')}`
124
+ const cmd = `$env:PYTHONIOENCODING='utf-8'; & ${PY} ${psq(join(CODESYS_TOOLS_DIR, script))} ${args.map(psq).join(' ')}`
109
125
  const policy = ctx.sandboxPolicy && exec && exec.agent
110
126
  ? ctx.sandboxPolicy.resolve({ session: exec.agent.session })
111
127
  : undefined
@@ -197,9 +213,10 @@ function requireDvp(dvp, usage) {
197
213
  return abs
198
214
  }
199
215
 
200
- /** 预建输出目录及其子目录(治 WinError 5:目录存在时 python makedirs exist_ok 不再报错)。 */
201
- function precreateOutDir(outd) {
202
- for (const sub of ['', 'extracted', 'pous']) {
216
+ /** 预建输出目录及其子目录(治 WinError 5:目录存在时 python makedirs exist_ok 不再报错)。
217
+ * 2026-11-05:dvp 产物只有「解码报告.md」,不再为它建 extracted/、pous/ 两个空目录。 */
218
+ function precreateOutDir(outd, kind = 'project') {
219
+ for (const sub of kind === 'dvp' ? [''] : ['', 'extracted', 'pous']) {
203
220
  try { mkdirSync(join(outd, sub), { recursive: true }) } catch { /* 留给 python */ }
204
221
  }
205
222
  }
@@ -209,7 +226,7 @@ function precreateOutDir(outd) {
209
226
  async function decodeCodesysProject(ctx, projPath, signal, exec, outDir, base) {
210
227
  const { abs, dir, kind } = requireProject(projPath, '需要一个 .project / .dvp 工程文件路径(带空格请加引号)')
211
228
  const outd = outDir || defaultArchiveDir(abs, base)
212
- precreateOutDir(outd)
229
+ precreateOutDir(outd, kind)
213
230
  const result = kind === 'dvp'
214
231
  ? await runPy(ctx, 'dvp_decode.py', [abs, '-o', outd], dir, signal, exec)
215
232
  : await runPy(ctx, 'read_project.py', [abs, outd], dir, signal, exec)
@@ -2587,7 +2604,11 @@ async function runDiff(ctx, oldP, newP, signal, base) {
2587
2604
 
2588
2605
  // ───────────────────────── 应用注册 ─────────────────────────
2589
2606
 
2590
- export function apply(ctx) {
2607
+ export function apply(ctx, config = {}) {
2608
+ // 2026-11-05:读插件配置的归档根目录(config.archiveDir)。不配置时保持旧行为不变。
2609
+ const cfg = config && typeof config === 'object' ? config : {}
2610
+ if (typeof cfg.archiveDir === 'string' && cfg.archiveDir.trim()) ARCHIVE_ROOT = cfg.archiveDir.trim()
2611
+
2591
2612
  // ── 工具一:统一入口(四格式自动识别分发)──
2592
2613
  ctx.tools.register({
2593
2614
  name: 'plc-decode',
@@ -2596,7 +2617,7 @@ export function apply(ctx) {
2596
2617
  type: 'object',
2597
2618
  properties: {
2598
2619
  file: { type: 'string', description: 'PLC 工程文件路径(.project/.dvp/.smart/.smartlib,绝对路径或相对当前工作目录)。' },
2599
- outDir: { type: 'string', description: '可选:输出目录;缺省写到工作区「解码归档\\<工程文件名>\\」(四格式一致,不写工程目录)。' },
2620
+ outDir: { type: 'string', description: '可选:输出目录;缺省写到「<归档根>\\<工程所在文件夹名>\\」(归档根默认「解码归档」,可用插件配置 archiveDir 改,见 /plc-archive;四格式一致,不写工程目录)。' },
2600
2621
  },
2601
2622
  required: ['file'],
2602
2623
  additionalProperties: false,
@@ -2732,7 +2753,7 @@ export function apply(ctx) {
2732
2753
  properties: {
2733
2754
  old: { type: 'string', description: '旧工程 .smart 文件路径(绝对路径或相对工作目录)。' },
2734
2755
  new: { type: 'string', description: '新工程 .smart 文件路径。' },
2735
- outDir: { type: 'string', description: '可选:输出目录;缺省写到工作区「解码归档\\<新工程文件名>\\」。' },
2756
+ outDir: { type: 'string', description: '可选:输出目录;缺省写到「<归档根>\\<新工程所在文件夹名>\\」(归档根默认「解码归档」,可用插件配置 archiveDir 改)。' },
2736
2757
  },
2737
2758
  required: ['old', 'new'],
2738
2759
  additionalProperties: false,
@@ -3195,6 +3216,28 @@ export function apply(ctx) {
3195
3216
  })
3196
3217
 
3197
3218
  // ── 命令 ──
3219
+ ctx.commands.register({
3220
+ name: 'plc-archive',
3221
+ description: '查看解码归档地址:当前生效的根目录、默认落点、怎么改配置',
3222
+ input: { hint: '(无参数)' },
3223
+ handler: async () => ({
3224
+ kind: 'success',
3225
+ text: [
3226
+ `当前生效的归档根目录: ${ARCHIVE_ROOT}`,
3227
+ `解析方式: ${isAbsolute(ARCHIVE_ROOT) ? '绝对路径,原样使用' : `相对路径,按会话工作区解析(本进程 cwd = ${process.cwd()})`}`,
3228
+ '默认落点: <归档根>\\<工程文件所在文件夹名>\\(同一文件夹里有多个工程文件时用 <文件夹名>_<文件名>)',
3229
+ '',
3230
+ '改配置(改完重启 dsh 生效):在 DSH profile 的 cordis.patch.yml 里给本插件那行加 config:',
3231
+ ' - id: dsh-plc-decode',
3232
+ ' name: dsh-plc-decode',
3233
+ ' config:',
3234
+ ' archiveDir: D:/plc/解码归档',
3235
+ '',
3236
+ '只改这一次、不写配置:解码时传 outDir 参数(工具参数或 /plc-decode 的第二个参数)。',
3237
+ ].join('\n'),
3238
+ }),
3239
+ })
3240
+
3198
3241
  ctx.commands.register({
3199
3242
  name: 'plc-decode',
3200
3243
  description: 'PLC 工程统一解码入口(自动识别 .project/.dvp/.smart/.smartlib)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plc-decode",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "在 DSH 里读 PLC 工程文件:CoDeSys .project、台达 .dvp、西门子 S7-200 SMART .smart/.smartlib 统一解码成可读报告与结构化数据,另带新旧工程对比、地址体检与连线还原。",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/scripts/smoke.mjs CHANGED
@@ -16,7 +16,7 @@ import { join } from 'node:path'
16
16
  import { apply, inject, name as pluginName } from '../index.js'
17
17
 
18
18
  const EXPECTED_TOOLS = 12
19
- const EXPECTED_COMMANDS = 11
19
+ const EXPECTED_COMMANDS = 12
20
20
 
21
21
  let failed = 0
22
22
  const pass = (m) => console.log(` ok ${m}`)
@@ -32,7 +32,8 @@ const INDEX_FILES = {
32
32
 
33
33
  /**
34
34
  * 用子进程代替 DSH 的 shell 服务:插件只依赖 resolve/execute/result 三个方法,
35
- * 这里把命令交给 PowerShell 执行,输出按 GBK 解码(与 DSH shell 服务行为一致)。
35
+ * 这里把命令交给 PowerShell 执行。插件在 runPy 里钉了 PYTHONIOENCODING=utf-8,
36
+ * python 输出是 UTF-8,所以这里也按 UTF-8 解码(2026-11-05 前是 GBK)。
36
37
  */
37
38
  function createShell() {
38
39
  return {
@@ -43,14 +44,14 @@ function createShell() {
43
44
  ['-NoProfile', '-NonInteractive', '-Command', spec.command],
44
45
  { cwd: spec.workdir || process.cwd(), timeout: spec.timeoutMs, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer' },
45
46
  )
46
- const gbk = new TextDecoder('gb18030')
47
+ const utf8 = new TextDecoder('utf-8')
47
48
  return {
48
49
  result: async () => ({
49
50
  exitCode: r.status ?? 1,
50
51
  timedOut: r.error?.code === 'ETIMEDOUT',
51
52
  aborted: false,
52
- stdout: { text: gbk.decode(r.stdout ?? Buffer.alloc(0)) },
53
- stderr: { text: gbk.decode(r.stderr ?? Buffer.alloc(0)) },
53
+ stdout: { text: utf8.decode(r.stdout ?? Buffer.alloc(0)) },
54
+ stderr: { text: utf8.decode(r.stderr ?? Buffer.alloc(0)) },
54
55
  }),
55
56
  }
56
57
  },