zcode-pro 0.17.1 → 0.18.0

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.
@@ -3,19 +3,20 @@
3
3
  // 仅绑定 127.0.0.1,并通过每次启动随机生成的 token 鉴权。
4
4
 
5
5
  import { createServer } from 'node:http';
6
- import { execFile, spawn } from 'node:child_process';
7
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { execFile, spawn, spawnSync } from 'node:child_process';
7
+ import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
9
9
  import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
10
10
  import { homedir, tmpdir } from 'node:os';
11
- import { pathToFileURL } from 'node:url';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
12
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
13
13
  import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap } from './taskIndex.mjs';
14
14
  import { pickFolderSystem } from './pickFolder.mjs';
15
15
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
16
- import { webdavRequest, webdavPutWithMkcol, webdavHttpText } from './webdav.mjs';
16
+ import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
17
+ import { resolveZcodeExecutable } from './paths.mjs';
17
18
 
18
- const VERSION = '0.17.1';
19
+ const VERSION = '0.18.0';
19
20
 
20
21
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
21
22
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -38,8 +39,10 @@ function defaultHeadroomFile() {
38
39
  return join(homedir(), '.zcode', 'headroom.json');
39
40
  }
40
41
 
41
- // 本插件市场(zcode-plugins 仓库)在 ZCode CLI 数据目录里的落点
42
+ // 本插件市场(zcode-plugins 仓库)在 ZCode CLI 数据目录里的落点。
43
+ // 市场未添加时(新机器)从这里自动补装,免得“安装”按钮因市场缺失永远失败。
42
44
  const ZCODE_PLUGINS_MARKETPLACE = 'duanluan-zcode-plugins';
45
+ const ZCODE_PLUGINS_MARKETPLACE_SOURCE = 'duanluan/zcode-plugins';
43
46
 
44
47
  function cliPluginsDir() {
45
48
  return join(homedir(), '.zcode', 'cli', 'plugins');
@@ -74,6 +77,7 @@ export function defaultConfig() {
74
77
  listItemSpacing: null, // 列表项之间的间距(应用默认 6px)
75
78
  quoteCodeSpacing: null, // 引用/代码块上下留白(应用默认 16px,my-4)
76
79
  lineHeight: null, // 回答行高,倍数(应用默认 1.75)
80
+ thinkingLineHeight: null, // 思考行高,倍数(应用默认 1.5)
77
81
  codeLineHeight: null, // 代码块行高,倍数(应用默认 12px 字号 × 20px 行盒,约 1.65)
78
82
  tableSpacing: null, // 表格上下边距(未设置时跟随段落间距)
79
83
  tableCellPaddingV: null, // 单元格上下边距(应用默认 3px)
@@ -88,9 +92,19 @@ export function defaultConfig() {
88
92
  },
89
93
  // 项目路径(规范化,无尾分隔符)→ 自定义别名。只影响界面渲染,不改动任何真实数据。
90
94
  aliases: {},
95
+ // headroom 启用开关的记忆(供应商 id → { proxy, direct }):关闭时把走 headroom 的
96
+ // 供应商地址切回直连并记录下来,再次开启时按记录把地址切回代理
97
+ headroomRoutes: {},
91
98
  // HTTP 代理(http(s)://host:port):helper 发起的网络访问走它——插件市场更新/安装
92
- // (zcode CLI → git)、headroom 本体的检查更新与升级(pip)。空 = 不用代理。
99
+ // (zcode CLI → git)、headroom/rtk 的安装与升级(pip/GitHub)。空 = 不用代理。
93
100
  proxy: '',
101
+ // 代理生效范围(按用途开关):plugins = 插件市场更新/安装/卸载;deps = 插件依赖
102
+ // 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
103
+ // 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
104
+ proxyScopes: { plugins: true, deps: true, sync: false },
105
+ // 开发模式(设置弹窗“调试”标签页):开启后出现「重载」按钮,可在不重启
106
+ // ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
107
+ devMode: false,
94
108
  // 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
95
109
  // 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
96
110
  // url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
@@ -126,6 +140,12 @@ export function loadConfig(configFile) {
126
140
  features: { ...defaultConfig().features, ...(saved.features || {}) },
127
141
  styles: { ...defaultConfig().styles, ...(saved.styles && typeof saved.styles === 'object' ? saved.styles : {}) },
128
142
  aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
143
+ headroomRoutes: { ...((saved.headroomRoutes && typeof saved.headroomRoutes === 'object' && !Array.isArray(saved.headroomRoutes)) ? saved.headroomRoutes : {}) },
144
+ proxyScopes: mergeProxyScopes(saved.proxyScopes),
145
+ devMode: saved.devMode === true,
146
+ // devActive 每次读取时重算(保存时会随配置一起写入文件,但读取方不用保存的值):
147
+ // 开发模式生效 = 配置开关或启动器带 ZCODEPRO_DEV=1,设置弹窗据此决定是否显示“调试”标签页(普通用户不可见)
148
+ devActive: saved.devMode === true || process.env.ZCODEPRO_DEV === '1',
129
149
  sync: mergeSyncConfig(saved.sync),
130
150
  };
131
151
  } catch {
@@ -186,12 +206,16 @@ function readBody(req) {
186
206
  });
187
207
  }
188
208
 
189
- // 软件代理(设置弹窗“代理”标签页):helper 发起的网络访问(zcode CLI→git、pip)
190
- // 统一走这里;startHelper 启动时读配置,保存代理时即时更新
209
+ // 软件代理(设置弹窗“代理”标签页):helper 发起的网络访问(zcode CLI→git、pip、
210
+ // WebDAV 同步)按用途(proxyScopes)走这里;startHelper 启动时读配置,
211
+ // 保存代理地址或范围开关时即时更新
191
212
  let activeProxyUrl = '';
213
+ let activeProxyScopes = { plugins: true, deps: true, sync: false };
192
214
 
193
- function proxyEnv(extra = {}) {
194
- if (!activeProxyUrl) return extra;
215
+ // scope:'plugins'(插件市场)/ 'deps'(headroom、rtk)/ 'sync'(WebDAV 同步);
216
+ // 'none' 表示这次调用与下载无关(headroom 生命周期钩子等),永不注入下载代理
217
+ function proxyEnv(extra = {}, scope = 'deps') {
218
+ if (scope === 'none' || !activeProxyUrl || !activeProxyScopes[scope]) return extra;
195
219
  return {
196
220
  HTTP_PROXY: activeProxyUrl, HTTPS_PROXY: activeProxyUrl,
197
221
  http_proxy: activeProxyUrl, https_proxy: activeProxyUrl,
@@ -199,11 +223,19 @@ function proxyEnv(extra = {}) {
199
223
  };
200
224
  }
201
225
 
226
+ // 该用途当前是否走代理:同步按 proxyUrl 参数传给 webdav(curl 路径用,
227
+ // fetch 兜底路径不认代理环境变量)
228
+ function proxyUrlFor(scope) {
229
+ return activeProxyScopes[scope] ? activeProxyUrl : '';
230
+ }
231
+
202
232
  export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
203
233
  const configFile = join(dataRoot, 'zcodepro.json');
204
234
  const settingsFile = join(dataRoot, 'v2', 'setting.json');
205
235
  migrateConfigOnce(configFile);
206
- activeProxyUrl = String(loadConfig(configFile).proxy || '').trim();
236
+ const bootConfig = loadConfig(configFile);
237
+ activeProxyUrl = String(bootConfig.proxy || '').trim();
238
+ activeProxyScopes = { ...bootConfig.proxyScopes };
207
239
 
208
240
  const server = createServer(async (req, res) => {
209
241
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
@@ -245,6 +277,16 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
245
277
  }
246
278
  activeProxyUrl = current.proxy;
247
279
  }
280
+ if (body && typeof body === 'object' && body.proxyScopes && typeof body.proxyScopes === 'object') {
281
+ // 代理生效范围按单项提交(每个开关只改自己那项),即时生效
282
+ for (const k of ['plugins', 'deps', 'sync']) {
283
+ if (typeof body.proxyScopes[k] === 'boolean') current.proxyScopes[k] = body.proxyScopes[k];
284
+ }
285
+ activeProxyScopes = { ...current.proxyScopes };
286
+ }
287
+ if (body && typeof body === 'object' && typeof body.devMode === 'boolean') {
288
+ current.devMode = body.devMode;
289
+ }
248
290
  if (body && typeof body === 'object' && body.features && typeof body.features === 'object') {
249
291
  applyFeaturesPartial(current.features, body.features);
250
292
  }
@@ -373,7 +415,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
373
415
  json(res, 500, { ok: false, code: 'vision-read', error: 'zcode-vision.json 不是合法 JSON' });
374
416
  return;
375
417
  }
376
- json(res, 200, { ok: true, config: parsed, file });
418
+ json(res, 200, { ok: true, config: parsed, file, hook: findVisionHook(), pluginVersion: installedPluginVersion('zcode-vision') });
377
419
  return;
378
420
  }
379
421
  if (req.method === 'POST' && url.pathname === '/vision') {
@@ -421,10 +463,14 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
421
463
  const dir = defaultRtkDir();
422
464
  const mode = readRtkMode(join(dir, 'mode'));
423
465
  const info = await rtkBinaryInfo();
466
+ // 已装插件探测:新版(≥1.3.0)钩子入口是跨平台 rtk-pretooluse.mjs,旧版是 .sh
467
+ const hook = findMarketplaceHook('rtk', join('hooks', 'rtk-pretooluse.mjs'))
468
+ || findMarketplaceHook('rtk', join('hooks', 'rtk-pretooluse.sh'));
424
469
  // rtkBinaryInfo 走 PATH 命中时 binPath 是命令名;换绝对路径用于面板展示
425
470
  json(res, 200, {
426
471
  ok: true, mode, whitelist: readRtkWhitelist(join(dir, 'whitelist')),
427
- dir, ...readRtkBuiltinWhitelist(), ...info,
472
+ dir, hook, pluginVersion: installedPluginVersion('rtk'),
473
+ ...readRtkBuiltinWhitelist(), ...info,
428
474
  binPath: info.binPath ? (rtkFindBin() || info.binPath) : null,
429
475
  });
430
476
  return;
@@ -498,12 +544,15 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
498
544
  json(res, 500, { ok: false, code: 'headroom-read', error: '读取 headroom.json 失败: ' + (err?.message || err) });
499
545
  return;
500
546
  }
501
- const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
547
+ // 已安装探测:新版(≥1.2.0)钩子入口是跨平台 ensure-proxy.mjs,旧版是 ensure-proxy.sh
548
+ const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.mjs'))
549
+ || findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
502
550
  // 插件版本取自缓存目录名(…/headroom/1.0.2/hooks/…)
503
551
  const vm = hook ? /[/\\]headroom[/\\](\d+(?:\.\d+){0,3})[/\\]/.exec(hook) : null;
504
552
  json(res, 200, {
505
553
  ok: true, config, file, hook,
506
554
  pluginVersion: vm ? vm[1] : null,
555
+ usage: headroomUsageScan(dataRoot),
507
556
  ...(await headroomBinaryInfo()),
508
557
  });
509
558
  return;
@@ -563,6 +612,15 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
563
612
  return;
564
613
  }
565
614
  output = r.output;
615
+ } else if (body && typeof body.usage === 'boolean') {
616
+ // 启用开关:只改 ZCode 供应商配置里的地址(走/不走本地压缩代理),不动代理进程
617
+ const r = setHeadroomUsage(dataRoot, configFile, body.usage);
618
+ if (!r.ok) {
619
+ json(res, 500, { ok: false, code: r.code, error: r.error });
620
+ return;
621
+ }
622
+ json(res, 200, { ok: true, usage: headroomUsageScan(dataRoot), changed: r.changed });
623
+ return;
566
624
  } else if (body && body.cancelUpgrade === true) {
567
625
  json(res, 200, { ok: true, upgrade: cancelHeadroomUpgrade() });
568
626
  return;
@@ -598,7 +656,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
598
656
  return;
599
657
  }
600
658
  } else {
601
- json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 action/backend/power/interval 之一' });
659
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 action/backend/power/usage/interval 之一' });
602
660
  return;
603
661
  }
604
662
  let config = null;
@@ -606,6 +664,41 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
606
664
  json(res, 200, { ok: true, output, config });
607
665
  return;
608
666
  }
667
+ // —— 开发模式重载(设置弹窗“调试”标签页):不重启 ZCode 换新代码 ——
668
+ if (req.method === 'POST' && url.pathname === '/dev/reload') {
669
+ if (loadConfig(configFile).devMode !== true && process.env.ZCODEPRO_DEV !== '1') {
670
+ json(res, 404, { ok: false, error: 'not found' });
671
+ return;
672
+ }
673
+ // 源码目录(有 package.json 与 esbuild)先构建,安装副本没有构建环境则跳过
674
+ let built = false;
675
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
676
+ const esbuildBin = [join(repoRoot, 'node_modules', 'esbuild', 'bin', 'esbuild'),
677
+ join(repoRoot, 'node_modules', 'esbuild', 'bin', 'esbuild.exe')]
678
+ .find((p) => existsSync(p)) || '';
679
+ if (esbuildBin) {
680
+ try {
681
+ const b = spawnSync(esbuildBin, ['src/inject/index.js', '--bundle',
682
+ '--format=iife', '--target=chrome120', '--outfile=dist/inject.js'],
683
+ { cwd: repoRoot, timeout: 60000, encoding: 'utf8', windowsHide: true });
684
+ built = !b.error && b.status === 0;
685
+ } catch { /* 构建失败也继续交接(可能只改了 host 端代码) */ }
686
+ }
687
+ json(res, 200, { ok: true, built });
688
+ // 先应答再交接:拉起新的启动器进程(同一入口与参数,读新构建的 inject.js
689
+ // 与新 host 代码),本进程随后退出。页面刷新由注入脚本的换代守卫触发
690
+ // (见 src/inject/index.js);新进程的 helper 端口占用靠监听重试等待
691
+ setTimeout(() => {
692
+ try {
693
+ const child = spawn(process.execPath, process.argv.slice(1), {
694
+ detached: true, stdio: 'ignore', windowsHide: true, env: process.env,
695
+ });
696
+ child.unref();
697
+ } catch { /* 拉不起来就只能等下次手动重启 */ }
698
+ setTimeout(() => process.exit(0), 250);
699
+ }, 150);
700
+ return;
701
+ }
609
702
  // —— 软件代理连通性检测:经 curl -x 走代理访问插件市场/本体升级实际要用的站点 ——
610
703
  if (req.method === 'POST' && url.pathname === '/proxy/test') {
611
704
  const body = await readBody(req);
@@ -639,11 +732,21 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
639
732
  ['GitHub', 'https://github.com'],
640
733
  ['PyPI', pypiTarget],
641
734
  ];
735
+ // Windows 精简环境 PATH 可能不含 curl:与同步功能共用 findCurl(补测 System32)
736
+ const curlBin = findCurl();
642
737
  const results = [];
643
738
  for (const [name, targetUrl] of targets) {
644
739
  results.push(await new Promise((resolveT) => {
645
740
  const started = Date.now();
646
- execFile('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '8', '-x', proxy, targetUrl],
741
+ if (!curlBin) {
742
+ resolveT({ name, httpCode: null, ms: null, ok: false, error: '未找到 curl 命令' });
743
+ return;
744
+ }
745
+ // -I 只取响应头:连通性检测不必下载整页(github 主页数百 KB,
746
+ // 经慢代理节点 8 秒必然超时误报)。-o 丢弃输出用各平台的空设备
747
+ // (Windows 上 /dev/null 是普通路径且目录不存在,必须用 NUL)
748
+ const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
749
+ execFile(curlBin, ['-sI', '-o', nullDev, '-w', '%{http_code}', '--max-time', '8', '-x', proxy, targetUrl],
647
750
  { timeout: 10000, windowsHide: true }, (err, stdout) => {
648
751
  if (err && err.code === 'ENOENT') {
649
752
  resolveT({ name, httpCode: null, ms: null, ok: false, error: '未找到 curl' });
@@ -699,7 +802,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
699
802
  return;
700
803
  }
701
804
  const config = loadConfig(configFile);
702
- const r = await runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl: activeProxyUrl });
805
+ const r = await runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl: proxyUrlFor('sync') });
703
806
  if (r.error) {
704
807
  json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
705
808
  return;
@@ -707,8 +810,132 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
707
810
  json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
708
811
  return;
709
812
  }
813
+ // —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
814
+ if (req.method === 'POST' && url.pathname === '/sync/test') {
815
+ const body = await readBody(req);
816
+ const v = validateSyncForm(body?.sync || {});
817
+ if (v.error) {
818
+ json(res, 400, { ok: false, code: 'sync-invalid', error: v.error });
819
+ return;
820
+ }
821
+ // dir 缺省时沿用已保存的值
822
+ const sync = { ...loadConfig(configFile).sync, ...v.sync };
823
+ const r = await runSyncTest({ sync, proxyUrl: proxyUrlFor('sync') });
824
+ if (r.error) {
825
+ json(res, r.status || 500, { ok: false, code: r.code || 'sync-test', error: r.error });
826
+ return;
827
+ }
828
+ json(res, 200, { ok: true, targets: r.targets, fileCount: r.fileCount });
829
+ return;
830
+ }
831
+ // —— 云端版本列表(“从云端下载”先弹文件选择,任选一个版本恢复)——
832
+ if (req.method === 'POST' && url.pathname === '/sync/versions') {
833
+ const config = loadConfig(configFile);
834
+ const sync = config.sync;
835
+ if (!sync.url || !/^https?:\/\/\S+$/.test(sync.url)) {
836
+ json(res, 400, { ok: false, code: 'sync-not-configured', error: '请先填写 WebDAV URL 并保存' });
837
+ return;
838
+ }
839
+ const r = await listSyncVersions(sync, proxyUrlFor('sync'));
840
+ if (r.error) {
841
+ json(res, r.status || 500, { ok: false, code: r.code || 'sync-request', error: r.error });
842
+ return;
843
+ }
844
+ json(res, 200, { ok: true, files: r.files });
845
+ return;
846
+ }
847
+ // —— 按文件操作(文件名必须整串匹配 SYNC_VERSION_RE):apply 导入 / delete 删除 / export 保存到磁盘 ——
848
+ if (req.method === 'POST' && url.pathname.startsWith('/sync/version/')) {
849
+ const action = url.pathname.slice('/sync/version/'.length);
850
+ if (!['apply', 'delete', 'export'].includes(action)) {
851
+ json(res, 404, { ok: false, error: 'not found' });
852
+ return;
853
+ }
854
+ const body = await readBody(req);
855
+ const file = typeof body?.file === 'string' ? body.file : '';
856
+ if (!SYNC_VERSION_RE.test(file)) {
857
+ json(res, 400, { ok: false, code: 'sync-file-invalid', error: '文件名不合法(只能是同步目录里的 zcode-pro 备份文件)' });
858
+ return;
859
+ }
860
+ const config = loadConfig(configFile);
861
+ const sync = config.sync;
862
+ if (!sync.url || !/^https?:\/\/\S+$/.test(sync.url)) {
863
+ json(res, 400, { ok: false, code: 'sync-not-configured', error: '请先填写 WebDAV URL 并保存' });
864
+ return;
865
+ }
866
+ const remoteMissing = () => json(res, 404, { ok: false, code: 'sync-remote-missing', error: `云端文件 ${file} 不存在(可能已被删除)` });
867
+ if (action === 'apply') {
868
+ const remote = await fetchSyncNamedDoc(sync, proxyUrlFor('sync'), file);
869
+ if (remote.error) {
870
+ json(res, remote.status || 500, { ok: false, code: remote.code, error: remote.error });
871
+ return;
872
+ }
873
+ if (remote.missing) {
874
+ remoteMissing();
875
+ return;
876
+ }
877
+ const kind = file.startsWith('model') ? 'model' : 'zcodepro';
878
+ sync.lastPullAt = Date.now();
879
+ const r = applySyncDocs({ docs: { [kind]: remote.doc.data }, dataRoot, configFile, config, sync });
880
+ json(res, 200, { ok: true, applied: r.applied, sync: loadConfig(configFile).sync });
881
+ return;
882
+ }
883
+ if (action === 'delete') {
884
+ const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, file), login: sync.login, password: sync.password, proxyUrl: proxyUrlFor('sync'), timeoutMs: 30000 });
885
+ if (!r.ok) {
886
+ json(res, 502, { ok: false, code: 'sync-request', error: r.error });
887
+ return;
888
+ }
889
+ if (r.httpCode === 404) {
890
+ remoteMissing();
891
+ return;
892
+ }
893
+ if (r.httpCode < 200 || r.httpCode >= 300) {
894
+ json(res, 502, { ok: false, code: 'sync-request', error: webdavHttpText(r.httpCode) });
895
+ return;
896
+ }
897
+ json(res, 200, { ok: true });
898
+ return;
899
+ }
900
+ // export:把云端文件原样保存到本机(不做信封校验,取回什么存什么)
901
+ const r = await webdavRequest({ method: 'GET', url: syncFileUrlByName(sync, file), login: sync.login, password: sync.password, proxyUrl: proxyUrlFor('sync'), timeoutMs: 60000 });
902
+ if (!r.ok) {
903
+ json(res, 502, { ok: false, code: 'sync-request', error: r.error });
904
+ return;
905
+ }
906
+ if (r.httpCode === 404 || r.httpCode === 409) {
907
+ remoteMissing();
908
+ return;
909
+ }
910
+ if (r.httpCode < 200 || r.httpCode >= 300) {
911
+ json(res, 502, { ok: false, code: 'sync-request', error: webdavHttpText(r.httpCode) });
912
+ return;
913
+ }
914
+ if (r.body.length > 8 * 1024 * 1024) {
915
+ json(res, 400, { ok: false, code: 'sync-remote-invalid', error: `云端 ${file} 超过 8MB,已拒绝保存` });
916
+ return;
917
+ }
918
+ // 系统目录选择器选保存位置;环境没有选择器时退回用户主目录,用户取消则中止
919
+ const picked = await pickFolderSystem(homedir(), '选择保存位置');
920
+ let target = '';
921
+ if (picked && typeof picked.path === 'string' && picked.path) target = join(picked.path, file);
922
+ else if (picked && picked.unavailable) target = join(homedir(), file);
923
+ else {
924
+ json(res, 200, { ok: false, code: 'export-cancelled', error: '已取消' });
925
+ return;
926
+ }
927
+ try {
928
+ writeFileSync(target, r.body, 'utf8');
929
+ } catch (err) {
930
+ json(res, 500, { ok: false, code: 'sync-export', error: `写入 ${target} 失败: ${err?.message || err}` });
931
+ return;
932
+ }
933
+ json(res, 200, { ok: true, path: target });
934
+ return;
935
+ }
710
936
  // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
711
937
  if (req.method === 'GET' && url.pathname === '/plugins/status') {
938
+ await ensureZcodePluginsMarketplace();
712
939
  if (Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
713
940
  await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
714
941
  lastMarketplaceSync = Date.now();
@@ -726,6 +953,8 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
726
953
  // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
727
954
  const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
728
955
  const steps = [];
956
+ const added = await ensureZcodePluginsMarketplace();
957
+ if (added) steps.push({ step: 'marketplace add', ok: added.ok, output: added.output || added.error });
729
958
  const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
730
959
  lastMarketplaceSync = Date.now();
731
960
  steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
@@ -742,7 +971,10 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
742
971
  const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
743
972
  steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
744
973
  if (r.ok) updated += 1;
745
- } else if (!p.installed && (body?.installMissing || only)) {
974
+ } else if (!p.installed && only) {
975
+ // 安装只走单插件入口(name 指定=各面板的安装卡):
976
+ // 全量「检查并更新」/启动自动更新只升级已装插件——卸载必须被尊重,
977
+ // 未装插件一律由用户在各 tab 显式点安装
746
978
  const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
747
979
  steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
748
980
  if (r.ok) updated += 1;
@@ -760,9 +992,24 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
760
992
  }
761
993
  });
762
994
 
995
+ // 端口被占时重试:开发重载的交接窗口内,旧进程退出略晚于新进程启动,
996
+ // 新进程在这里等它让出端口(最多 20 秒),而不是启动失败
763
997
  return new Promise((resolveStarted, rejectStarted) => {
764
- server.once('error', rejectStarted);
765
- server.listen(port, '127.0.0.1', () => resolveStarted(server));
998
+ let attempts = 0;
999
+ const tryListen = () => {
1000
+ attempts += 1;
1001
+ server.removeAllListeners('error');
1002
+ server.once('error', (err) => {
1003
+ if (err && err.code === 'EADDRINUSE' && attempts < 40) {
1004
+ try { server.close(); } catch { /* ignore */ }
1005
+ setTimeout(tryListen, 500);
1006
+ return;
1007
+ }
1008
+ rejectStarted(err);
1009
+ });
1010
+ server.listen(port, '127.0.0.1', () => resolveStarted(server));
1011
+ };
1012
+ tryListen();
766
1013
  });
767
1014
  }
768
1015
 
@@ -785,6 +1032,18 @@ function clampInt(v, min, max, dflt) {
785
1032
 
786
1033
  // —— 设置同步(“同步”标签页)的实现 ——
787
1034
 
1035
+ // loadConfig 时把磁盘上的 proxyScopes 按默认值归一(只收布尔,缺的补默认:
1036
+ // 插件市场/依赖软件开、同步关)
1037
+ function mergeProxyScopes(saved) {
1038
+ const d = defaultConfig().proxyScopes;
1039
+ const s = (saved && typeof saved === 'object') ? saved : {};
1040
+ return {
1041
+ plugins: typeof s.plugins === 'boolean' ? s.plugins : d.plugins,
1042
+ deps: typeof s.deps === 'boolean' ? s.deps : d.deps,
1043
+ sync: typeof s.sync === 'boolean' ? s.sync : d.sync,
1044
+ };
1045
+ }
1046
+
788
1047
  // loadConfig 时把磁盘上的 sync 段按默认值归一(长度限幅、时间戳只收数字)。
789
1048
  // 当前唯一后端是 WebDAV,type 一律归为 webdav(不做“禁用”态:同步本就仅手动触发)
790
1049
  function mergeSyncConfig(saved) {
@@ -832,6 +1091,18 @@ function validateSyncForm(raw) {
832
1091
  // 上传/下载按勾选逐文件进行,可单独覆盖其中一项 ——
833
1092
  const SYNC_FILES = { zcodepro: 'zcodepro.json', model: 'model.json' };
834
1093
 
1094
+ // 云端版本文件名的合法形态:固定名(zcodepro.json / model.json,始终是最新一版)
1095
+ // 或组名 + ISO 时间戳(冒号换成 -,部分网盘文件名不允许冒号),如
1096
+ // zcodepro-2025-10-25T14-59-53-169Z.json。按文件操作的端点(导入/删除/导出)
1097
+ // 都先整串匹配该正则,杜绝 ../ 之类的路径注入
1098
+ const SYNC_VERSION_RE = /^(zcodepro|model)(?:-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)?\.json$/;
1099
+
1100
+ // 历史版本文件名(同一次上传的各组共用同一时间戳,像“一次备份”而不是两个时刻)
1101
+ function syncVersionName(kind, ts) {
1102
+ const iso = ts.toISOString(); // 2025-10-25T14:59:53.169Z
1103
+ return `${kind}-${iso.slice(0, 19).replace(/:/g, '-')}-${iso.slice(20, 23)}Z.json`;
1104
+ }
1105
+
835
1106
  // 云端目录 = URL(服务器地址,容忍尾斜杠)+ 上传目录(默认 zcode-pro,可多级)。
836
1107
  // 目录段逐段编码,中文/空格等文件夹名也能正确落到地址里
837
1108
  function syncBaseDir(sync) {
@@ -846,6 +1117,25 @@ function syncFileUrl(sync, kind) {
846
1117
  return syncBaseDir(sync) + '/' + SYNC_FILES[kind];
847
1118
  }
848
1119
 
1120
+ // 拼出云端目录里某个已校验文件名(固定名或历史版本)的完整地址
1121
+ function syncFileUrlByName(sync, fileName) {
1122
+ return syncBaseDir(sync) + '/' + encodeURIComponent(fileName);
1123
+ }
1124
+
1125
+ // 信封校验:云端文件必须是 { app: 'zcode-pro', kind, format, data } 且组别与文件名
1126
+ // 前缀一致;4MB 上限挡住异常大文件
1127
+ function parseSyncEnvelope(body, kind, label) {
1128
+ if (body.length > 4 * 1024 * 1024) {
1129
+ return { error: `云端 ${label} 超过 4MB,已拒绝应用`, code: 'sync-remote-invalid', status: 400 };
1130
+ }
1131
+ let doc = null;
1132
+ try { doc = JSON.parse(body); } catch { /* 落到统一报错 */ }
1133
+ if (!doc || typeof doc !== 'object' || doc.app !== 'zcode-pro' || doc.kind !== kind || !doc.data || typeof doc.data !== 'object') {
1134
+ return { error: `云端 ${label} 不是 zcode-pro 的同步数据(可能被其他内容占用),请删除该文件或换一个目录`, code: 'sync-remote-invalid', status: 409 };
1135
+ }
1136
+ return { doc };
1137
+ }
1138
+
849
1139
  // 读云端某个同步文件:{ doc } 成功 | { missing } 云端没有该组数据 | { error, code, status } 失败。
850
1140
  // 文件里已是其他内容(或组别不符)时拒绝覆盖;4MB 上限挡住异常大文件。
851
1141
  async function fetchSyncDoc(sync, proxyUrl, kind) {
@@ -856,15 +1146,19 @@ async function fetchSyncDoc(sync, proxyUrl, kind) {
856
1146
  if (r.httpCode === 404 || r.httpCode === 409) return { missing: true };
857
1147
  if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
858
1148
  if (!r.body.trim()) return { missing: true }; // 个别网盘对不存在的文件返回 200 空内容
859
- if (r.body.length > 4 * 1024 * 1024) {
860
- return { error: `云端 ${SYNC_FILES[kind]} 超过 4MB,已拒绝应用`, code: 'sync-remote-invalid', status: 400 };
861
- }
862
- let doc = null;
863
- try { doc = JSON.parse(r.body); } catch { /* 落到统一报错 */ }
864
- if (!doc || typeof doc !== 'object' || doc.app !== 'zcode-pro' || doc.kind !== kind || !doc.data || typeof doc.data !== 'object') {
865
- return { error: `云端已存在其他内容的 ${SYNC_FILES[kind]},请换一个目录`, code: 'sync-remote-invalid', status: 409 };
866
- }
867
- return { doc };
1149
+ return parseSyncEnvelope(r.body, kind, SYNC_FILES[kind]);
1150
+ }
1151
+
1152
+ // 读云端某个版本文件(固定名或带时间戳的历史版本,文件名需已过 SYNC_VERSION_RE)。
1153
+ // 返回值约定与 fetchSyncDoc 相同;kind 由文件名前缀决定
1154
+ async function fetchSyncNamedDoc(sync, proxyUrl, fileName) {
1155
+ const kind = fileName.startsWith('model') ? 'model' : 'zcodepro';
1156
+ const r = await webdavRequest({ method: 'GET', url: syncFileUrlByName(sync, fileName), login: sync.login, password: sync.password, proxyUrl });
1157
+ if (!r.ok) return { error: r.error, code: 'sync-request', status: 502 };
1158
+ if (r.httpCode === 404 || r.httpCode === 409) return { missing: true };
1159
+ if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
1160
+ if (!r.body.trim()) return { missing: true };
1161
+ return parseSyncEnvelope(r.body, kind, fileName);
868
1162
  }
869
1163
 
870
1164
  async function runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl }) {
@@ -880,8 +1174,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
880
1174
  : runSyncPull({ dataRoot, configFile, config, sync, proxyUrl });
881
1175
  }
882
1176
 
883
- // 上传:每组独立一个云端文件(zcodepro.json / model.json),勾选几组写几个;
884
- // 写前预读只为确认没顶到别人的文件,自己的文件直接整份覆盖
1177
+ // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1178
+ // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1179
+ // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1180
+ // 恢复(旧版 zcode-pro 仍读固定名)
885
1181
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
886
1182
  const docs = {};
887
1183
  if (sync.include.zcodepro) {
@@ -904,22 +1200,34 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
904
1200
  return { code: 'sync-run', status: 400, error: '本机没有可同步的内容' };
905
1201
  }
906
1202
  const pushed = [];
1203
+ const stamp = new Date(); // 各组共用同一时间戳,对应“一次备份”
907
1204
  const dir = await mkdtemp(join(tmpdir(), 'zcodepro-sync-'));
908
1205
  try {
909
1206
  for (const kind of Object.keys(docs)) {
910
1207
  const remote = await fetchSyncDoc(sync, proxyUrl, kind);
911
1208
  if (remote.error) return remote;
912
- const doc = { app: 'zcode-pro', kind, format: 1, savedAt: new Date().toISOString(), data: docs[kind] };
1209
+ // 别名先与云端合并:本机没配过的条目从云端带上,别让这次上传把别的机器配的别名顶掉
1210
+ if (kind === 'zcodepro' && remote.doc) {
1211
+ const remoteAliases = sanitizeAliases(remote.doc.data?.aliases);
1212
+ if (remoteAliases) docs.zcodepro.aliases = mergeAliases(docs.zcodepro.aliases, remoteAliases);
1213
+ }
1214
+ const doc = { app: 'zcode-pro', kind, format: 1, savedAt: stamp.toISOString(), data: docs[kind] };
913
1215
  const docPath = join(dir, SYNC_FILES[kind]);
914
1216
  writeFileSync(docPath, JSON.stringify(doc, null, 2), 'utf8');
915
- const put = await webdavPutWithMkcol({
916
- url: syncFileUrl(sync, kind), login: sync.login, password: sync.password,
1217
+ const putUrl = (name) => webdavPutWithMkcol({
1218
+ url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password,
917
1219
  uploadFile: docPath, proxyUrl, timeoutMs: 60000,
918
1220
  });
1221
+ const put = await putUrl(SYNC_FILES[kind]);
919
1222
  if (!put.ok) return { code: 'sync-request', status: 502, error: put.error };
920
1223
  if (put.httpCode < 200 || put.httpCode >= 300) {
921
1224
  return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
922
1225
  }
1226
+ const putVersion = await putUrl(syncVersionName(kind, stamp));
1227
+ if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1228
+ if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1229
+ return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1230
+ }
923
1231
  pushed.push(kind);
924
1232
  }
925
1233
  } finally {
@@ -931,8 +1239,8 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
931
1239
  }
932
1240
 
933
1241
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
934
- // 覆盖前留 .zcodepro-backup 备份;zcodepro 组只覆盖功能/样式/别名/代理,
935
- // sync 配置与凭据始终保留本机的
1242
+ // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名与本机的
1243
+ // 合并(合并规则见 mergeAliases);sync 配置与凭据始终保留本机的
936
1244
  async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
937
1245
  // 先把勾选组的云端数据全部读到(任何一组读取失败都不动本机文件),再逐组应用
938
1246
  const docs = {};
@@ -945,6 +1253,14 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
945
1253
  if (!Object.keys(docs).length) {
946
1254
  return { code: 'sync-remote-missing', status: 404, error: '云端没有同步数据,请先在其他电脑上传' };
947
1255
  }
1256
+ sync.lastPullAt = Date.now();
1257
+ return applySyncDocs({ docs, dataRoot, configFile, config, sync });
1258
+ }
1259
+
1260
+ // 把云端读到的各组数据落到本机(runSyncPull 与按文件的历史版本导入共用):
1261
+ // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名与本机的合并,
1262
+ // model 组覆盖 v2/config.json 的 provider 与 provider_config.json;覆盖前都留备份
1263
+ function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
948
1264
  const applied = [];
949
1265
  const backupFile = (p) => {
950
1266
  try { if (existsSync(p)) copyFileSync(p, p + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
@@ -954,15 +1270,16 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
954
1270
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
955
1271
  renameSync(tmp, p);
956
1272
  };
957
- sync.lastPullAt = Date.now();
958
1273
 
959
1274
  if (docs.zcodepro) {
960
1275
  const rz = docs.zcodepro;
961
1276
  const next = loadConfig(configFile);
962
1277
  applyFeaturesPartial(next.features, rz.features);
963
1278
  next.styles = applyStylesPartial(next.styles, rz.styles);
964
- const aliases = sanitizeAliases(rz.aliases);
965
- if (aliases) next.aliases = aliases;
1279
+ // 别名与本机的合并(本机配过的优先),不再整份替换——各机器项目路径不同,
1280
+ // 合并后两边的别名都在,谁也不会被对方的同步顶掉
1281
+ const remoteAliases = sanitizeAliases(rz.aliases);
1282
+ if (remoteAliases) next.aliases = mergeAliases(next.aliases, remoteAliases);
966
1283
  if (typeof rz.proxy === 'string') {
967
1284
  const v = rz.proxy.trim();
968
1285
  if (v === '' || /^https?:\/\/\S+:\d+$/.test(v)) next.proxy = v;
@@ -988,10 +1305,79 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
988
1305
  }
989
1306
  applied.push('model');
990
1307
  }
1308
+ // 没动 zcodepro.json 时,lastPullAt 也要写入配置文件(动了则已随 next 一起保存)
991
1309
  if (!applied.includes('zcodepro')) saveConfig(configFile, config);
992
1310
  return { applied };
993
1311
  }
994
1312
 
1313
+ // 同步“测试”按钮的实现:分三步验证,任一步失败即止(后续步骤没有意义)。
1314
+ // ① 连接与凭据:PROPFIND 上传目录——404/409 只说明目录还没建,能拿到 HTTP 响应
1315
+ // 且不是 401/403 即证明服务器可达、凭据可用(坚果云的应用密码可限定目录,
1316
+ // 探服务器根会误报);② 上传目录:不存在时逐段 MKCOL 建出来再验(顺带验证写
1317
+ // 权限,与上传时的自动建目录一致);③ 文件列表:PROPFIND Depth 1 数备份文件。
1318
+ // targets 的 step 供前端映射本地化步骤名(connect/dir/list)
1319
+ async function runSyncTest({ sync, proxyUrl }) {
1320
+ const auth = { login: sync.login, password: sync.password, proxyUrl };
1321
+ const targets = [];
1322
+ const probe = async (step, run) => {
1323
+ const started = Date.now();
1324
+ const resp = await run();
1325
+ const ok = resp.ok && resp.httpCode >= 200 && resp.httpCode < 300;
1326
+ targets.push({
1327
+ step, ok,
1328
+ ms: Date.now() - started,
1329
+ httpCode: resp.ok ? resp.httpCode : null,
1330
+ error: ok ? '' : (resp.ok ? webdavHttpText(resp.httpCode) : resp.error),
1331
+ });
1332
+ return { ok, resp };
1333
+ };
1334
+ const baseDir = syncBaseDir(sync);
1335
+ // ① 只要拿到响应且不是凭据问题就算连通(404/409 = 目录还没建,交给第②步处理)
1336
+ const connect = await probe('connect', () => webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 20000, headers: ['Depth: 0'] }));
1337
+ if (!connect.ok && connect.resp.ok && (connect.resp.httpCode === 404 || connect.resp.httpCode === 409)) {
1338
+ // 目录缺失不算连接失败:改写第一步结果,继续建目录
1339
+ targets[targets.length - 1] = { ...targets[targets.length - 1], ok: true, error: '' };
1340
+ } else if (!connect.ok) {
1341
+ return { targets };
1342
+ }
1343
+ // ② 目录不存在则逐段 MKCOL(已存在段 MKCOL 多半回 405,忽略),再 PROPFIND 验证
1344
+ const dir = await probe('dir', async () => {
1345
+ let r = connect.resp;
1346
+ if (r.httpCode === 404 || r.httpCode === 409) {
1347
+ try {
1348
+ const u = new URL(baseDir);
1349
+ let cur = u.origin;
1350
+ for (const s of u.pathname.split('/').filter(Boolean)) {
1351
+ cur += '/' + s;
1352
+ await webdavRequest({ method: 'MKCOL', url: cur, ...auth, timeoutMs: 15000 });
1353
+ }
1354
+ } catch { /* URL 解析失败:交给下面的 PROPFIND 报错 */ }
1355
+ r = await webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 20000, headers: ['Depth: 0'] });
1356
+ }
1357
+ return r;
1358
+ });
1359
+ if (!dir.ok) return { targets };
1360
+ // ③ 列文件(Depth 1),数出同步目录里的备份文件个数
1361
+ const list = await probe('list', () => webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 30000, headers: ['Depth: 1'] }));
1362
+ if (!list.ok) return { targets };
1363
+ const fileCount = parseWebdavListing(list.resp.body).filter((e) => SYNC_VERSION_RE.test(e.name)).length;
1364
+ return { targets, fileCount };
1365
+ }
1366
+
1367
+ // PROPFIND 云端同步目录,列出全部备份版本(固定名 + 带时间戳的历史):
1368
+ // 按修改时间从新到旧(时间缺失的按文件名排后);目录还不存在视为空列表
1369
+ async function listSyncVersions(sync, proxyUrl) {
1370
+ const r = await webdavRequest({ method: 'PROPFIND', url: syncBaseDir(sync), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000, headers: ['Depth: 1'] });
1371
+ if (!r.ok) return { error: r.error, code: 'sync-request', status: 502 };
1372
+ if (r.httpCode === 404 || r.httpCode === 409) return { files: [] };
1373
+ if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
1374
+ const files = parseWebdavListing(r.body)
1375
+ .filter((e) => SYNC_VERSION_RE.test(e.name))
1376
+ .map((e) => ({ name: e.name, size: e.size, lastModified: e.lastModified }));
1377
+ files.sort((a, b) => ((b.lastModified || 0) - (a.lastModified || 0)) || b.name.localeCompare(a.name));
1378
+ return { files };
1379
+ }
1380
+
995
1381
  // 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
996
1382
  function sanitizeAliases(raw) {
997
1383
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -1006,6 +1392,26 @@ function sanitizeAliases(raw) {
1006
1392
  return out;
1007
1393
  }
1008
1394
 
1395
+ // 别名表合并(同步上传/下载共用):取两边的并集,配过别名的条目优先——同一路径
1396
+ // 两边都配了别名时用本机的;只有一边配过时保留配过的那个。所以在一台机器上清除
1397
+ // 别名后,只要别的机器还配着,下次同步还会把这条带回来,两边都清掉才会真正消失。
1398
+ // 导出以便测试脚本直接驱动。
1399
+ export function mergeAliases(localRaw, remoteRaw) {
1400
+ const out = {};
1401
+ const put = (k, v) => {
1402
+ if (typeof k !== 'string' || k.length > 500 || k.includes('\0')) return;
1403
+ if (typeof v !== 'string' || !v || v.length > 200 || /[\r\n\0]/.test(v)) return;
1404
+ out[k] = v;
1405
+ };
1406
+ const entries = (raw) => (raw && typeof raw === 'object' && !Array.isArray(raw)) ? Object.entries(raw) : [];
1407
+ for (const [k, v] of entries(remoteRaw)) put(k, v);
1408
+ for (const [k, v] of entries(localRaw)) {
1409
+ // 本机配过的覆盖云端同路径条目;本机是空值(视为没配过)时不覆盖,云端配过的留下
1410
+ if (v) put(k, v);
1411
+ }
1412
+ return out;
1413
+ }
1414
+
1009
1415
  // features 部分更新(POST /config 与同步下载共用):只收已知开关的布尔值
1010
1416
  function applyFeaturesPartial(base, partial) {
1011
1417
  const out = (base && typeof base === 'object') ? base : {};
@@ -1232,7 +1638,7 @@ function applyStylesPartial(base, partial) {
1232
1638
  else if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 96) out[key] = Math.round(v);
1233
1639
  }
1234
1640
  }
1235
- for (const key of ['lineHeight', 'codeLineHeight', 'userLineHeight']) {
1641
+ for (const key of ['lineHeight', 'thinkingLineHeight', 'codeLineHeight', 'userLineHeight']) {
1236
1642
  if (key in partial) {
1237
1643
  const v = partial[key];
1238
1644
  if (v === null) out[key] = null;
@@ -1447,16 +1853,22 @@ function readRtkBuiltinWhitelist() {
1447
1853
  // —— rtk 本体(独立二进制)的检查更新与升级:GitHub Releases 下载 → sha256 校验 → 原子替换 ——
1448
1854
 
1449
1855
  function rtkFindBin() {
1856
+ // 兜底候选按平台给全扩展名:全新安装装到 ~/.local/bin 后 PATH 对运行中的 helper
1857
+ // 不可见(需注销重登),必须能按固定位置直接找到,否则面板误报“未安装”
1450
1858
  return findBinFile(
1451
- process.platform === 'win32' ? ['rtk.exe', 'rtk.cmd'] : ['rtk'],
1452
- [join(homedir(), '.local', 'bin', 'rtk'), join(homedir(), 'miniforge3', 'bin', 'rtk'),
1453
- '/usr/local/bin/rtk', '/usr/bin/rtk']);
1454
- }
1455
-
1456
- // 最新版本探测(两条互补通道,任一成功即用):
1457
- // ① releases/latest 的 302 重定向拿 tag——不受 API 速率限制,但 github.com 网页在
1458
- // 部分网络直连不通(超时);② 匿名 GitHub API——直连常可用,但走代理时出口 IP
1459
- // 常被限流(403 无 tag_name)。资产与 checksums 的下载地址按官方命名规则构造。
1859
+ process.platform === 'win32' ? ['rtk.exe'] : ['rtk'],
1860
+ process.platform === 'win32'
1861
+ ? [join(homedir(), '.local', 'bin', 'rtk.exe'), join(homedir(), '.cargo', 'bin', 'rtk.exe'),
1862
+ join(homedir(), 'miniforge3', 'Scripts', 'rtk.exe'), join(homedir(), 'miniconda3', 'Scripts', 'rtk.exe')]
1863
+ : [join(homedir(), '.local', 'bin', 'rtk'), join(homedir(), 'miniforge3', 'bin', 'rtk'),
1864
+ '/usr/local/bin/rtk', '/usr/bin/rtk']);
1865
+ }
1866
+
1867
+ // 最新版本探测。两条互补通道,任一成功即用:① 匿名 GitHub API——直连常可用,但
1868
+ // 走代理时出口 IP 常被限流;② releases/latest 302 的 Location 头——不受速率限制,
1869
+ // 但 github.com 网页在部分网络直连不通。资产与 checksums 的下载地址按官方命名规则
1870
+ // 构造。请求复用 webdavRequest(curl 优先、按 proxyScopes 注入代理;无 curl 的精简
1871
+ // Windows 回退 fetch 直连,代理在那条路径不生效)。
1460
1872
  async function rtkLatestInfo() {
1461
1873
  const assetName = rtkAssetName();
1462
1874
  if (!assetName) return { code: 'rtk-update', error: '当前平台不支持自动升级' };
@@ -1464,25 +1876,23 @@ async function rtkLatestInfo() {
1464
1876
  const base = `https://github.com/rtk-ai/rtk/releases/download/v${tag}`;
1465
1877
  return { tag, assetName, assetUrl: `${base}/${assetName}`, sumsUrl: `${base}/checksums.txt` };
1466
1878
  };
1467
- let redirErr = '';
1468
- const redir = await execCapture('curl', ['-sS', '-o', '/dev/null', '-w', '%{url_effective}', '-L', '--max-time', '6',
1469
- 'https://github.com/rtk-ai/rtk/releases/latest'], 8000);
1470
- if (redir.ok) {
1471
- const m = /\/tag\/v?(\d[\w.+-]*)\s*$/.exec(redir.output.trim());
1472
- if (m) return mkInfo(m[1]);
1473
- redirErr = '重定向未携带版本';
1474
- } else {
1475
- redirErr = (redir.output || redir.error || '').split('\n').slice(-1)[0];
1476
- }
1477
- const api = await execCapture('curl', ['-s', '--max-time', '8',
1478
- 'https://api.github.com/repos/rtk-ai/rtk/releases/latest'], 10000);
1479
- if (api.ok) {
1879
+ const proxyUrl = proxyUrlFor('deps');
1880
+ const api = await webdavRequest({
1881
+ method: 'GET', url: 'https://api.github.com/repos/rtk-ai/rtk/releases/latest',
1882
+ proxyUrl, timeoutMs: 12000, headers: ['User-Agent: zcode-pro'],
1883
+ });
1884
+ if (api.ok && api.httpCode === 200) {
1480
1885
  try {
1481
- const tag = String((JSON.parse(api.output) || {}).tag_name || '').replace(/^v/, '');
1886
+ const tag = String(JSON.parse(api.body).tag_name || '').replace(/^v/, '');
1482
1887
  if (tag) return mkInfo(tag);
1483
- } catch { /* 落到统一报错 */ }
1888
+ } catch { /* body 不是 JSON:换重定向通道 */ }
1484
1889
  }
1485
- return { code: 'rtk-update', error: '查询 GitHub Releases 失败(可尝试在“代理”标签页设置代理)。重定向: ' + redirErr };
1890
+ const redir = await webdavRequest({
1891
+ method: 'GET', url: 'https://github.com/rtk-ai/rtk/releases/latest', proxyUrl, timeoutMs: 10000,
1892
+ });
1893
+ const m = /\/tag\/v?(\d[\w.+-]*)\s*$/.exec(redir.redirectUrl || '');
1894
+ if (m) return mkInfo(m[1]);
1895
+ return { code: 'rtk-update', error: '查询 GitHub Releases 失败(网络不通或需代理)' };
1486
1896
  }
1487
1897
 
1488
1898
  // 按平台/架构选资产文件名(与官方 Release 命名一致)
@@ -1499,72 +1909,92 @@ export function rtkUpgradeSnapshot() { return upgradeJobSnapshot(rtkUpgradeJob);
1499
1909
 
1500
1910
  function cancelRtkUpgrade() { return cancelUpgradeJob(rtkUpgradeJob); }
1501
1911
 
1502
- // 启动 rtk 本体升级(后台任务,面板经 GET /rtk/upgrade 轮询):
1503
- // 下载资产与 checksums → sha256 校验 → 解压 → 试运行 → 原子替换旧二进制
1912
+ // 启动 rtk 本体升级或安装(后台任务,面板经 GET /rtk/upgrade 轮询):
1913
+ // 下载资产与 checksums → sha256 校验 → 解压 → 试运行 → 原子替换旧二进制;
1914
+ // 未安装时装到 ~/.local/bin(钩子的 rtk 查找清单含该位置,装完钩子立即可用;
1915
+ // Windows 资产是 zip,用系统自带 bsdtar 解压、PowerShell Get-FileHash 校验,
1916
+ // 全新安装后把 ~/.local/bin 注册进用户级 PATH,运行中的进程读不到——重启 ZCode 生效)
1504
1917
  function startRtkUpgrade() {
1505
1918
  if (rtkUpgradeJob && rtkUpgradeJob.running) return rtkUpgradeSnapshot();
1506
1919
  const j = newUpgradeJob();
1507
1920
  rtkUpgradeJob = j;
1921
+ const WIN = process.platform === 'win32';
1508
1922
  void (async () => {
1509
1923
  let dir = null;
1510
1924
  let bin = null;
1511
1925
  try {
1512
- if (process.platform === 'win32') throw new Error('Windows 暂不支持自动升级,请手动下载 Release 覆盖安装');
1513
- bin = rtkFindBin();
1514
- if (!bin) throw new Error('未找到 rtk 程序');
1926
+ const freshInstall = !rtkFindBin();
1927
+ bin = rtkFindBin() || join(homedir(), '.local', 'bin', WIN ? 'rtk.exe' : 'rtk');
1515
1928
  const info = await rtkLatestInfo();
1516
1929
  if (info.code) throw new Error(info.error);
1517
1930
  dir = await mkdtemp(join(tmpdir(), 'rtk-upgrade-'));
1518
1931
  j.output += `下载 ${info.assetName}…\n`;
1519
1932
  const archPath = join(dir, info.assetName);
1520
- await jobRun(j, 'curl', ['-L', '--fail', '--progress-bar', '-o', archPath, info.assetUrl], 600000);
1933
+ await downloadToFile(j, info.assetUrl, archPath, 600000);
1521
1934
  // sha256 校验(checksums.txt 与本机摘要工具;失败即中止,不替换)
1522
1935
  if (info.sumsUrl) {
1523
1936
  const sumPath = join(dir, 'checksums.txt');
1524
- await jobRun(j, 'curl', ['-sL', '--fail', '-o', sumPath, info.sumsUrl], 30000);
1937
+ await downloadToFile(j, info.sumsUrl, sumPath, 30000);
1525
1938
  const want = readFileSync(sumPath, 'utf8').split('\n')
1526
1939
  .find((l) => l.trim().endsWith(info.assetName))?.trim().split(/\s+/)[0];
1527
- const sumCmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum';
1528
- const sumArgs = process.platform === 'darwin' ? ['-a', '256', archPath] : [archPath];
1529
- const local = await execCapture(sumCmd, sumArgs, 30000);
1530
- const got = local.ok ? (local.output.split(/\s+/)[0] || '') : '';
1940
+ let got = '';
1941
+ if (WIN) {
1942
+ const local = await execCapture('powershell.exe',
1943
+ ['-NoProfile', '-Command', `(Get-FileHash -Algorithm SHA256 -LiteralPath "${archPath}").Hash`], 60000);
1944
+ got = (/[0-9a-fA-F]{64}/.exec(local.output || '') || [''])[0];
1945
+ } else {
1946
+ const sumCmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum';
1947
+ const sumArgs = process.platform === 'darwin' ? ['-a', '256', archPath] : [archPath];
1948
+ const local = await execCapture(sumCmd, sumArgs, 30000);
1949
+ got = local.ok ? (local.output.split(/\s+/)[0] || '') : '';
1950
+ }
1531
1951
  if (!want || !got || want.toLowerCase() !== got.toLowerCase()) {
1532
1952
  throw new Error(`sha256 校验失败(期望 ${want || '?'},实际 ${got || '?'})`);
1533
1953
  }
1534
1954
  j.output += 'sha256 校验通过\n';
1535
1955
  }
1536
- // 解压并定位二进制(包内根目录或一级子目录)
1537
- await jobRun(j, 'tar', ['-xzf', archPath, '-C', dir], 60000);
1538
- let newBin = join(dir, 'rtk');
1956
+ // 解压并定位二进制(包内根目录或一级子目录)。Windows 资产是 zip,
1957
+ // 系统自带 bsdtar(tar.exe)可直接解,不加 -z
1958
+ await jobRun(j, 'tar', WIN ? ['-xf', archPath, '-C', dir] : ['-xzf', archPath, '-C', dir], 60000);
1959
+ const exeName = WIN ? 'rtk.exe' : 'rtk';
1960
+ let newBin = join(dir, exeName);
1539
1961
  if (!existsSync(newBin)) {
1540
1962
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
1541
- const cand = join(dir, ent.name, 'rtk');
1963
+ const cand = join(dir, ent.name, exeName);
1542
1964
  if (ent.isDirectory() && existsSync(cand)) { newBin = cand; break; }
1543
1965
  }
1544
1966
  }
1545
1967
  if (!existsSync(newBin)) throw new Error('解压后未找到 rtk 二进制');
1546
- chmodSync(newBin, 0o755);
1968
+ if (!WIN) chmodSync(newBin, 0o755);
1547
1969
  const v = await binVersionOf(newBin);
1548
1970
  if (!v) throw new Error('新版本无法运行(--version 无输出)');
1549
- // 原子替换:旧文件先挪走,失败可回滚
1971
+ // 放置:全新安装建目录直接放;已有旧文件则原子替换(先挪走,失败可回滚)
1972
+ if (freshInstall) mkdirSync(dirname(bin), { recursive: true });
1550
1973
  const backup = bin + '.rtk-old';
1551
1974
  try { rmSync(backup, { force: true }); } catch { /* ignore */ }
1552
- renameSync(bin, backup);
1975
+ const hadOld = existsSync(bin);
1976
+ if (hadOld) renameSync(bin, backup);
1553
1977
  try {
1554
1978
  try {
1555
1979
  renameSync(newBin, bin);
1556
1980
  } catch (renameErr) {
1557
1981
  // 跨文件系统(EXDEV)等 rename 失败:退化为复制(复制再失败由外层 catch 回滚)
1558
1982
  copyFileSync(newBin, bin);
1559
- chmodSync(bin, 0o755);
1983
+ if (!WIN) chmodSync(bin, 0o755);
1560
1984
  }
1561
1985
  } catch (err) {
1562
- renameSync(backup, bin);
1986
+ if (hadOld && existsSync(backup)) { try { renameSync(backup, bin); } catch { /* ignore */ } }
1563
1987
  throw err;
1564
1988
  }
1565
- try { rmSync(backup, { force: true }); } catch { /* ignore */ }
1989
+ try { if (existsSync(backup)) rmSync(backup, { force: true }); } catch { /* ignore */ }
1566
1990
  j.version = v;
1567
- j.output += `已升级到 ${v}\n`;
1991
+ if (WIN && freshInstall) {
1992
+ const inPath = await ensureUserPathEntry(dirname(bin));
1993
+ j.output += `已安装 rtk ${v}(${bin};PATH 需注销重登后对会话生效,插件钩子即时生效)\n`;
1994
+ if (!inPath) j.output += '注意:把 ~/.local/bin 写入用户 PATH 失败,钩子不受影响,会话里直接跑 rtk 需手动加 PATH\n';
1995
+ } else {
1996
+ j.output += freshInstall ? `已安装 rtk ${v}(${bin})\n` : `已升级到 ${v}\n`;
1997
+ }
1568
1998
  } catch (err) {
1569
1999
  if (!j.canceled) j.error = j.timedOut ? '升级超时已终止' : String(err?.stack || err?.message || err);
1570
2000
  } finally {
@@ -1577,26 +2007,39 @@ function startRtkUpgrade() {
1577
2007
  return rtkUpgradeSnapshot();
1578
2008
  }
1579
2009
 
1580
- // 在插件缓存里找市场插件的钩子脚本(取版本号最大的目录,按数值比较,0.10.0 > 0.9.0)。
1581
- // 只认纯数字版本目录:手动安装/升级的残留(如 1.0.2.bak-1.0.2)内容不全,不能选中。
2010
+ // 把目录追加进用户级 PATH(HKCU\Environment,Windows):保持原值类型不展开(REG_EXPAND_SZ
2011
+ // 里的 %VAR% 引用原样保留),已含该目录则不动。不广播环境变更——运行中的进程读不到,
2012
+ // 重启 ZCode 后新会话生效(rtk 钩子按固定清单找 rtk,不受 PATH 影响)。
2013
+ async function ensureUserPathEntry(dir) {
2014
+ const q = await execCapture('reg', ['query', 'HKCU\\Environment', '/v', 'PATH'], 15000);
2015
+ if (!q.ok) {
2016
+ const add = await execCapture('reg', ['add', 'HKCU\\Environment', '/v', 'PATH', '/t', 'REG_EXPAND_SZ', '/d', `%USERPROFILE%\\.local\\bin`, '/f'], 15000);
2017
+ return add.ok;
2018
+ }
2019
+ const m = /PATH\s+(REG_SZ|REG_EXPAND_SZ)\s+(.*)/i.exec(q.output.replace(/\r?\n/g, ' ').trim());
2020
+ if (!m) return false;
2021
+ const cur = m[2].trim();
2022
+ const norm = (s) => s.replace(/[/\\]+/g, '\\').replace(/\\+$/, '').toLowerCase();
2023
+ const expanded = cur.replaceAll('%USERPROFILE%', homedir());
2024
+ if (expanded.split(';').some((p) => p && norm(p) === norm(dir))) return true;
2025
+ const next = cur ? `${cur};${dir}` : dir;
2026
+ const r = await execCapture('reg', ['add', 'HKCU\\Environment', '/v', 'PATH', '/t', m[1], '/d', next, '/f'], 15000);
2027
+ return r.ok;
2028
+ }
2029
+
2030
+ // 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
2031
+ // 不能按目录扫描:卸载只删「当前指向」的版本目录,缓存里的历史版本目录会残留
2032
+ // (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
2033
+ // 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
1582
2034
  // 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
1583
2035
  // 属预期限制——正式发布从 duanluan-zcode-plugins 安装后即可用。
1584
2036
  function findMarketplaceHook(pluginName, hookRelPath) {
1585
- const base = join(cliPluginsDir(), 'cache', ZCODE_PLUGINS_MARKETPLACE, pluginName);
1586
- try {
1587
- const versions = readdirSync(base)
1588
- .filter((d) => /^\d+(\.\d+){0,3}$/.test(d))
1589
- .sort((a, b) => {
1590
- const pa = a.split('.').map(Number);
1591
- const pb = b.split('.').map(Number);
1592
- for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
1593
- return 0;
1594
- });
1595
- for (let i = versions.length - 1; i >= 0; i--) {
1596
- const hook = join(base, versions[i], hookRelPath);
1597
- if (existsSync(hook)) return hook;
1598
- }
1599
- } catch { /* ignore */ }
2037
+ const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2038
+ for (const rec of (installedRaw?.plugins || [])) {
2039
+ if (rec?.name !== pluginName || rec?.marketplace !== ZCODE_PLUGINS_MARKETPLACE || !rec.installPath) continue;
2040
+ const hook = join(rec.installPath, hookRelPath);
2041
+ if (existsSync(hook)) return hook;
2042
+ }
1600
2043
  return null;
1601
2044
  }
1602
2045
 
@@ -1604,6 +2047,13 @@ function findVisionHook() {
1604
2047
  return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
1605
2048
  }
1606
2049
 
2050
+ // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示「插件 x.y.z」用)
2051
+ function installedPluginVersion(pluginName) {
2052
+ const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2053
+ const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
2054
+ return rec?.version || null;
2055
+ }
2056
+
1607
2057
  // —— headroom 配置读写与状态解析(与 headroom-lib.sh 共用 ~/.zcode/headroom.json)——
1608
2058
 
1609
2059
  // 读配置并按插件默认值归一(文件缺失/键非法 → 默认)。导出以便测试脚本直接驱动。
@@ -1675,11 +2125,11 @@ function cancelUpgradeJob(job) {
1675
2125
  return upgradeJobSnapshot(job);
1676
2126
  }
1677
2127
 
1678
- // 升级任务的可取消子进程执行:输出喂给任务对象供面板轮询,环境注入代理
1679
- function jobRun(j, cmd, args, timeoutMs) {
2128
+ // 升级任务的可取消子进程执行:输出喂给任务对象供面板轮询,环境按用途注入代理
2129
+ function jobRun(j, cmd, args, timeoutMs, scope = 'deps') {
1680
2130
  return new Promise((resolveRun, rejectRun) => {
1681
2131
  if (j.canceled) { rejectRun(new Error('canceled')); return; }
1682
- const child = spawn(cmd, args, { env: { ...process.env, ...proxyEnv() }, windowsHide: true });
2132
+ const child = spawn(cmd, args, { env: { ...process.env, ...proxyEnv({}, scope) }, windowsHide: true });
1683
2133
  j.kill = () => { try { child.kill('SIGKILL'); } catch { /* ignore */ } };
1684
2134
  const feed = (d) => { j.output = (j.output + d.toString()).slice(-65536); };
1685
2135
  child.stdout.on('data', feed);
@@ -1698,10 +2148,62 @@ function jobRun(j, cmd, args, timeoutMs) {
1698
2148
  });
1699
2149
  }
1700
2150
 
2151
+ // rtk 资产/校验和下载:有 curl 时经子进程下载(jobRun 注入 deps 代理并提供取消
2152
+ // 与超时;-f 让 HTTP 错误直接失败,不会把错误页写成文件,此路径无百分比进度)。
2153
+ // 无 curl 的精简 Windows 用 node fetch 流式写文件兜底:进度按 5% 一档喂给升级
2154
+ // 任务,j.canceled 时立即中止(fetch 不读代理环境变量,配了代理又无 curl 的机器
2155
+ // 在这条路径只能直连——与 WebDAV 同步的 fetch 兜底同一限制)
2156
+ async function downloadToFile(j, url, dest, timeoutMs = 600000) {
2157
+ const curl = findCurl();
2158
+ if (curl) {
2159
+ await jobRun(j, curl,
2160
+ ['-fsSL', '--connect-timeout', '20', '--max-time', String(Math.ceil(timeoutMs / 1000)), '-o', dest, url],
2161
+ timeoutMs);
2162
+ return;
2163
+ }
2164
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2165
+ if (!res.ok || !res.body) throw new Error(`下载失败 HTTP ${res.status}`);
2166
+ const total = Number(res.headers.get('content-length')) || 0;
2167
+ const out = createWriteStream(dest);
2168
+ let got = 0;
2169
+ let lastPct = -1;
2170
+ try {
2171
+ for await (const chunk of res.body) {
2172
+ if (j.canceled) throw new Error('canceled');
2173
+ if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
2174
+ got += chunk.length;
2175
+ if (total > 0) {
2176
+ const pct = Math.floor((got / total) * 100);
2177
+ if (pct >= lastPct + 5) { lastPct = pct; j.output += `下载中 ${pct}%\n`; }
2178
+ }
2179
+ }
2180
+ await new Promise((resolve, reject) => { out.end(resolve); out.once('error', reject); });
2181
+ } catch (err) {
2182
+ try { out.destroy(); } catch { /* ignore */ }
2183
+ throw err;
2184
+ }
2185
+ }
2186
+
1701
2187
  // headroom 程序探测:镜像钩子 hr_find_headroom 的查找清单(PATH + 常见安装位置)
1702
2188
  function headroomFindBin() {
1703
- return findBinFile(
1704
- process.platform === 'win32' ? ['headroom.exe', 'headroom.cmd'] : ['headroom'],
2189
+ // Windows 兜底:pip 的 console script 落在各 Python 环境的 Scripts 目录(.exe),
2190
+ // Scripts 目录不一定在桌面应用的 PATH 里
2191
+ if (process.platform === 'win32') {
2192
+ const home = homedir();
2193
+ const pfRoots = [...new Set([process.env.PROGRAMFILES || 'C:\\Program Files', 'C:\\Program Files', 'C:\\ProgramFiles'])];
2194
+ const extra = [
2195
+ join(home, '.local', 'bin', 'headroom.exe'),
2196
+ join(home, 'miniforge3', 'Scripts', 'headroom.exe'),
2197
+ join(home, 'miniconda3', 'Scripts', 'headroom.exe'),
2198
+ join(home, 'anaconda3', 'Scripts', 'headroom.exe'),
2199
+ ];
2200
+ for (const pf of pfRoots) {
2201
+ extra.push(join(pf, 'miniforge3', 'Scripts', 'headroom.exe'), join(pf, 'miniconda3', 'Scripts', 'headroom.exe'));
2202
+ for (const v of ['313', '312', '311']) extra.push(join(pf, `Python${v}`, 'Scripts', 'headroom.exe'));
2203
+ }
2204
+ return findBinFile(['headroom.exe'], extra);
2205
+ }
2206
+ return findBinFile(['headroom'],
1705
2207
  [join(homedir(), '.local', 'bin', 'headroom'), join(homedir(), 'miniforge3', 'bin', 'headroom'),
1706
2208
  join(homedir(), '.cargo', 'bin', 'headroom'), '/usr/local/bin/headroom', '/usr/bin/headroom']);
1707
2209
  }
@@ -1712,10 +2214,11 @@ async function headroomBinaryInfo() {
1712
2214
  return { installed: true, binPath, version: await binVersionOf(binPath) };
1713
2215
  }
1714
2216
 
1715
- // 通用命令执行(headroom 本体的 pip 检查/升级用),输出合并 stdout+stderr
1716
- function execCapture(cmd, args, timeoutMs = 30000) {
2217
+ // 通用命令执行(headroom 本体的 pip 检查/升级等),输出合并 stdout+stderr,
2218
+ // 环境按用途注入代理(默认 deps:headroom/rtk;市场路径不走这里)
2219
+ function execCapture(cmd, args, timeoutMs = 30000, scope = 'deps') {
1717
2220
  return new Promise((resolveRun) => {
1718
- execFile(cmd, args, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv() }, windowsHide: true }, (err, stdout, stderr) => {
2221
+ execFile(cmd, args, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv({}, scope) }, windowsHide: true }, (err, stdout, stderr) => {
1719
2222
  resolveRun({
1720
2223
  ok: !err,
1721
2224
  code: err ? (err.code ?? 'error') : 0,
@@ -1729,6 +2232,19 @@ function execCapture(cmd, args, timeoutMs = 30000) {
1729
2232
  // headroom 本体(pip 安装)的升级通道:控制台脚本 shebang 指向安装它的 Python
1730
2233
  // 解释器,检查更新与升级都通过该环境的 pip 执行(尊重用户 pip 源配置)。
1731
2234
  function headroomPythonOf(binPath) {
2235
+ // Windows:pip console script 是 .exe(无 shebang),从所在目录推导——
2236
+ // Scripts\headroom.exe → 同环境根目录的 python.exe
2237
+ if (process.platform === 'win32') {
2238
+ const dir = dirname(binPath);
2239
+ if (basename(dir).toLowerCase() === 'scripts') {
2240
+ const py = join(dirname(dir), 'python.exe');
2241
+ if (existsSync(py)) return py;
2242
+ }
2243
+ const sibling = join(dir, 'python.exe');
2244
+ if (existsSync(sibling)) return sibling;
2245
+ return headroomFallbackPython();
2246
+ }
2247
+ // POSIX:读脚本的 shebang 行
1732
2248
  try {
1733
2249
  const first = (readFileSync(binPath, 'utf8').split('\n')[0] || '').trim();
1734
2250
  const m = /^#!\s*(.*python\S*)/.exec(first);
@@ -1747,9 +2263,35 @@ const HEADROOM_DIST_PY = [
1747
2263
  " print(n + '\\t' + d.version); break",
1748
2264
  ].join('\n');
1749
2265
 
2266
+ // 本体未装时的全新安装通道:找可用的 Python(PATH + 常见安装位置),
2267
+ // 用官方发行版 headroom-ai[all] 走同一套 pip 升级任务
2268
+ function headroomFallbackPython() {
2269
+ const WIN = process.platform === 'win32';
2270
+ const home = homedir();
2271
+ if (WIN) {
2272
+ const pfRoots = [...new Set([process.env.PROGRAMFILES || 'C:\\Program Files', 'C:\\Program Files', 'C:\\ProgramFiles'])];
2273
+ const extra = [
2274
+ join(home, 'miniforge3', 'python.exe'),
2275
+ join(home, 'miniconda3', 'python.exe'),
2276
+ join(home, 'anaconda3', 'python.exe'),
2277
+ ];
2278
+ for (const pf of pfRoots) {
2279
+ extra.push(join(pf, 'miniforge3', 'python.exe'), join(pf, 'miniconda3', 'python.exe'));
2280
+ for (const v of ['313', '312', '311']) extra.push(join(pf, `Python${v}`, 'python.exe'));
2281
+ }
2282
+ return findBinFile(['python.exe'], extra);
2283
+ }
2284
+ return findBinFile(['python3', 'python'],
2285
+ ['/usr/bin/python3', '/usr/local/bin/python3', join(home, '.local', 'bin', 'python3')]);
2286
+ }
2287
+
1750
2288
  async function headroomPipChannel() {
1751
2289
  const bin = headroomFindBin();
1752
- if (!bin) return { code: 'headroom-update', error: '未找到 headroom 程序' };
2290
+ if (!bin) {
2291
+ const py = headroomFallbackPython();
2292
+ if (!py) return { code: 'headroom-update', error: '未找到 headroom 程序,也未找到可用的 Python(全新安装需要 Python ≥ 3.10)' };
2293
+ return { py, dist: 'headroom-ai[all]', current: null, freshInstall: true };
2294
+ }
1753
2295
  const py = headroomPythonOf(bin);
1754
2296
  if (!py) return { code: 'headroom-update', error: '无法确定 headroom 的 Python 安装来源(不是 pip 控制台脚本)' };
1755
2297
  const r = await execCapture(py, ['-c', HEADROOM_DIST_PY], 20000);
@@ -1791,15 +2333,27 @@ function startHeadroomUpgrade(channel) {
1791
2333
  return headroomUpgradeSnapshot();
1792
2334
  }
1793
2335
 
1794
- // 运行 headroom 插件钩子(ensure-proxy.sh,POSIX sh):backend/power 写配置并立即
1795
- // 生效,status/start/stop/restart 管理代理生命周期
1796
- function runHeadroomHook(args, timeoutMs = 20000) {
2336
+ // 运行 headroom 插件钩子:新版插件(≥1.2.0)是跨平台 node 入口 ensure-proxy.mjs
2337
+ // (Windows 原生实现、POSIX 内部委托 ensure-proxy.sh),经 runNodeScript 跑(自动
2338
+ // 处理 node/内嵌 node 回退);旧版只有 .sh 时用 sh 直跑(仅 POSIX)。
2339
+ // backend/power 写配置并立即生效,status/start/stop/restart 管理代理生命周期。
2340
+ // 钩子运行注意两点:
2341
+ // 1. 不注入下载代理(scope 'none')——钩子不是安装/升级下载,代理范围开关不管它;
2342
+ // 2. localhost 强制不走代理——钩子里 curl 探测 http://127.0.0.1:<端口> 判断代理
2343
+ // 是否在跑,环境里残留的 http_proxy(包括 zcode-pro 自己注入的下载代理)会把
2344
+ // 探测请求送进代理服务器,代理停了就把在跑的 headroom 误判成未运行,面板一直
2345
+ // 卡在“正在启动”。
2346
+ async function runHeadroomHook(args, timeoutMs = 30000) {
2347
+ const noProxy = ['127.0.0.1', 'localhost', process.env.NO_PROXY || process.env.no_proxy || ''].filter(Boolean).join(',');
2348
+ const hookEnv = { NO_PROXY: noProxy, no_proxy: noProxy };
2349
+ const mjs = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.mjs'));
2350
+ if (mjs) return runNodeScript([mjs, ...args], timeoutMs, 'none', hookEnv);
1797
2351
  const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
1798
2352
  if (!hook) {
1799
- return Promise.resolve({ ok: false, code: 'headroom-plugin', error: '未找到 headroom 插件(先在插件市场安装)' });
2353
+ return { ok: false, code: 'headroom-plugin', error: '未找到 headroom 插件(先在插件市场安装)' };
1800
2354
  }
1801
2355
  return new Promise((resolveRun) => {
1802
- execFile('sh', [hook, ...args], { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
2356
+ execFile('sh', [hook, ...args], { timeout: timeoutMs, windowsHide: true, env: { ...process.env, ...hookEnv } }, (err, stdout, stderr) => {
1803
2357
  if (err && err.code === 'ENOENT') {
1804
2358
  resolveRun({ ok: false, code: 'headroom-sh', error: '未找到 sh,无法运行 headroom 插件脚本' });
1805
2359
  return;
@@ -1840,11 +2394,102 @@ export function parseHeadroomStatus(text) {
1840
2394
  };
1841
2395
  }
1842
2396
 
2397
+ // —— headroom 启用开关(设置弹窗“Headroom”标签页右上角):只切换 ZCode 供应商配置
2398
+ // 里指向本地压缩代理(http://127.0.0.1:<端口>)的条目与直连上游之间的地址,绝不碰
2399
+ // 代理进程——headroom 是共享程序,其他工具(如 codex)可能同时在用。
2400
+ // 关闭时把条目改回直连地址(headroom 的默认转发上游),并在 zcodepro.json 的
2401
+ // headroomRoutes 里记下 { 供应商 id → { proxy, direct } };开启时按记录切回代理。
2402
+ const HEADROOM_DIRECT_URL = 'https://open.bigmodel.cn/api/anthropic';
2403
+ const HEADROOM_LOCAL_URL_RE = /^http:\/\/127\.0\.0\.1:\d+\/?$/;
2404
+
2405
+ // 扫描 v2/config.json(provider.<id>.options.baseURL)与 v2/provider_config.json
2406
+ // (providerRules[].config.api.baseUrl),统计当前走本地代理的条目数
2407
+ export function headroomUsageScan(dataRoot) {
2408
+ let via = 0;
2409
+ const v2 = readJsonFile(join(dataRoot, 'v2', 'config.json'));
2410
+ for (const p of Object.values((v2 && typeof v2.provider === 'object' && v2.provider) || {})) {
2411
+ const u = typeof p?.options?.baseURL === 'string' ? p.options.baseURL.trim() : '';
2412
+ if (HEADROOM_LOCAL_URL_RE.test(u)) via += 1;
2413
+ }
2414
+ const pc = readJsonFile(join(dataRoot, 'v2', 'provider_config.json'));
2415
+ for (const r of pc?.config?.providerConfigRules?.providerRules || []) {
2416
+ const u = typeof r?.config?.api?.baseUrl === 'string' ? r.config.api.baseUrl.trim() : '';
2417
+ if (HEADROOM_LOCAL_URL_RE.test(u)) via += 1;
2418
+ }
2419
+ return { enabled: via > 0, entries: via };
2420
+ }
2421
+
2422
+ // 切换启用状态:enable=false 时 127.0.0.1 条目 → 直连(并记录);enable=true 时按
2423
+ // 记录把直连条目切回代理。改写前各留 .zcodepro-backup 备份,与同步导入同款。
2424
+ export function setHeadroomUsage(dataRoot, configFile, enable) {
2425
+ const backupFile = (p) => {
2426
+ try { if (existsSync(p)) copyFileSync(p, p + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
2427
+ };
2428
+ const atomicWrite = (p, obj) => {
2429
+ const tmp = p + '.zcodepro-tmp';
2430
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
2431
+ renameSync(tmp, p);
2432
+ };
2433
+ const v2File = join(dataRoot, 'v2', 'config.json');
2434
+ const pcFile = join(dataRoot, 'v2', 'provider_config.json');
2435
+ const v2 = readJsonFile(v2File);
2436
+ const pc = readJsonFile(pcFile);
2437
+ if (!v2 && !pc) return { ok: false, code: 'headroom-provider-config', error: '未找到 ZCode 供应商配置(v2/config.json)' };
2438
+
2439
+ const config = loadConfig(configFile);
2440
+ const routes = { ...(config.headroomRoutes || {}) };
2441
+ let changed = 0;
2442
+ // 供应商 id 来自配置文件,跳过保留键:对普通对象按 ["__proto__"] 赋值改的是原型,
2443
+ // 不是自有属性,记录会悄悄丢失
2444
+ const reserved = (id) => id === '__proto__' || id === 'constructor' || id === 'prototype';
2445
+ const apply = (get, set, id) => {
2446
+ const u = get();
2447
+ if (!enable && HEADROOM_LOCAL_URL_RE.test(u)) {
2448
+ routes[id] = { proxy: u, direct: HEADROOM_DIRECT_URL };
2449
+ set(HEADROOM_DIRECT_URL);
2450
+ changed += 1;
2451
+ return true;
2452
+ }
2453
+ if (enable && routes[id] && u === routes[id].direct) {
2454
+ set(routes[id].proxy);
2455
+ changed += 1;
2456
+ return true;
2457
+ }
2458
+ return false;
2459
+ };
2460
+
2461
+ if (v2 && v2.provider && typeof v2.provider === 'object') {
2462
+ let touched = false;
2463
+ for (const [id, p] of Object.entries(v2.provider)) {
2464
+ if (reserved(id) || !p || typeof p !== 'object' || !p.options || typeof p.options !== 'object') continue;
2465
+ const u = typeof p.options.baseURL === 'string' ? p.options.baseURL.trim() : '';
2466
+ if (apply(() => u, (v) => { p.options.baseURL = v; }, id)) touched = true;
2467
+ }
2468
+ if (touched) { backupFile(v2File); atomicWrite(v2File, v2); }
2469
+ }
2470
+ if (Array.isArray(pc?.config?.providerConfigRules?.providerRules)) {
2471
+ let touched = false;
2472
+ for (const r of pc.config.providerConfigRules.providerRules) {
2473
+ const id = typeof r?.providerId === 'string' ? r.providerId : '';
2474
+ const api = r?.config?.api;
2475
+ if (!id || reserved(id) || !api || typeof api.baseUrl !== 'string') continue;
2476
+ const u = api.baseUrl.trim();
2477
+ if (apply(() => u, (v) => { api.baseUrl = v; }, id)) touched = true;
2478
+ }
2479
+ if (touched) { backupFile(pcFile); atomicWrite(pcFile, pc); }
2480
+ }
2481
+ config.headroomRoutes = routes;
2482
+ saveConfig(configFile, config);
2483
+ return { ok: true, changed };
2484
+ }
2485
+
1843
2486
  // 用 node 跑脚本:优先 PATH 里的 node;桌面环境没有时用自身进程
1844
2487
  // (zcode-pro 本就跑在 ELECTRON_RUN_AS_NODE=1 的 ZCode 二进制上)。导出以便测试。
1845
- export function runNodeScript(scriptArgs, timeoutMs = 120000) {
1846
- const attempt = (cmd, extraEnv) => new Promise((resolveRun) => {
1847
- execFile(cmd, scriptArgs, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv(), ...extraEnv }, windowsHide: true }, (err, stdout, stderr) => {
2488
+ // scope 默认 deps(headroom、rtk 的安装与升级);插件市场的 zcode CLI 传 plugins;
2489
+ // extraEnv 是调用方要额外覆盖的环境变量(如 NO_PROXY)
2490
+ export function runNodeScript(scriptArgs, timeoutMs = 120000, scope = 'deps', extraEnv = {}) {
2491
+ const attempt = (cmd, runEnv) => new Promise((resolveRun) => {
2492
+ execFile(cmd, scriptArgs, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv({}, scope), ...extraEnv, ...runEnv }, windowsHide: true }, (err, stdout, stderr) => {
1848
2493
  if (err && err.code === 'ENOENT') { resolveRun(null); return; }
1849
2494
  resolveRun({
1850
2495
  ok: !err,
@@ -1857,25 +2502,34 @@ export function runNodeScript(scriptArgs, timeoutMs = 120000) {
1857
2502
  return attempt('node', {}).then((r) => r || attempt(process.execPath, { ELECTRON_RUN_AS_NODE: '1' }));
1858
2503
  }
1859
2504
 
1860
- // ZCode CLI bundle(plugins 子命令入口):按常见安装位置查找,可用环境变量覆盖
2505
+ // ZCode CLI bundle(plugins 子命令入口):优先从定位到的 ZCode 可执行文件推导
2506
+ // (paths.mjs 覆盖 --zcode-path/环境变量/平台默认位置/Windows 注册表,含
2507
+ // C:\Program Files\ZCode 安装),再退回常见安装位置,可用环境变量覆盖
1861
2508
  function zcodeCliBundleCandidates() {
2509
+ const fromExe = [];
2510
+ try {
2511
+ const exe = resolveZcodeExecutable();
2512
+ if (exe) fromExe.push(join(dirname(exe), 'resources', 'glm', 'zcode.cjs'));
2513
+ } catch { /* 未找到 ZCode 可执行文件时走静态候选 */ }
1862
2514
  return [
1863
2515
  process.env.ZCODEPRO_ZCODE_CLI,
2516
+ ...fromExe,
1864
2517
  '/opt/ZCode/resources/glm/zcode.cjs',
1865
2518
  '/usr/lib/zcode/resources/glm/zcode.cjs',
1866
2519
  '/usr/share/zcode/resources/glm/zcode.cjs',
1867
2520
  '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs',
1868
2521
  process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs') : null,
2522
+ process.env.PROGRAMFILES ? join(process.env.PROGRAMFILES, 'ZCode', 'resources', 'glm', 'zcode.cjs') : null,
1869
2523
  ].filter(Boolean);
1870
2524
  }
1871
2525
 
1872
- // 运行 zcode CLI 子命令(plugins …)
2526
+ // 运行 zcode CLI 子命令(plugins …,走 plugins 代理范围)
1873
2527
  function runZcodeCli(subArgs, timeoutMs = 120000) {
1874
2528
  const bundle = zcodeCliBundleCandidates().find((c) => existsSync(c));
1875
2529
  if (!bundle) {
1876
2530
  return Promise.resolve({ ok: false, code: 'cli-not-found', error: '未找到 ZCode CLI(resources/glm/zcode.cjs)' });
1877
2531
  }
1878
- return runNodeScript([bundle, ...subArgs], timeoutMs);
2532
+ return runNodeScript([bundle, ...subArgs], timeoutMs, 'plugins');
1879
2533
  }
1880
2534
 
1881
2535
  // 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
@@ -1896,6 +2550,21 @@ export function pluginUpdateStatus() {
1896
2550
  return { plugins, updates: plugins.filter((e) => e.hasUpdate) };
1897
2551
  }
1898
2552
 
2553
+ // 市场未添加时自动补装(新机器上设置面板的“安装/更新”按钮一键可用):
2554
+ // 读 known_marketplaces.json 判断,缺失则跑 plugins marketplace add。
2555
+ // 失败 30 分钟内不重试(add 是 git clone,GitHub 不通时每次面板请求都会白等
2556
+ // 最长 120 秒);返回 null = 已存在无需处理,{ ok, cached } = 执行或跳过了 add。
2557
+ let lastMarketplaceAddFail = 0;
2558
+ async function ensureZcodePluginsMarketplace() {
2559
+ const known = readJsonFile(join(cliPluginsDir(), 'known_marketplaces.json'));
2560
+ const ids = new Set((known?.marketplaces || []).map((m) => m?.id).filter(Boolean));
2561
+ if (ids.has(ZCODE_PLUGINS_MARKETPLACE)) return null;
2562
+ if (Date.now() - lastMarketplaceAddFail < 30 * 60 * 1000) return { ok: false, cached: true, error: '上次添加市场失败,30 分钟内不再自动重试' };
2563
+ const r = await runZcodeCli(['plugins', 'marketplace', 'add', ZCODE_PLUGINS_MARKETPLACE_SOURCE], 120000);
2564
+ if (!r.ok) lastMarketplaceAddFail = Date.now();
2565
+ return { ok: r.ok, output: r.output, error: r.error };
2566
+ }
2567
+
1899
2568
  // 读全局提示词(~/.zcode/AGENTS.md);文件不存在视为空内容。
1900
2569
  export function readAgents(agentsFile) {
1901
2570
  try {