zcode-pro 0.20.2 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zcode-pro",
3
- "version": "0.20.2",
3
+ "version": "1.0.0",
4
4
  "description": "ZCode Pro — 为 ZCode 桌面版增加界面增强(不修改客户端文件,通过 ZCode Pro 快捷方式启动即可),核心功能:项目自定义别名",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,13 +11,13 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'no
11
11
  import { homedir, tmpdir, userInfo } from 'node:os';
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
14
- import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel } from './taskIndex.mjs';
14
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel, listVirtualProjects } from './taskIndex.mjs';
15
15
  import { pickFolderSystem } from './pickFolder.mjs';
16
16
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
17
17
  import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
18
18
  import { resolveZcodeExecutable } from './paths.mjs';
19
19
 
20
- const VERSION = '0.20.2';
20
+ const VERSION = '1.0.0';
21
21
 
22
22
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
23
23
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -56,7 +56,6 @@ export function defaultConfig() {
56
56
  projectMenu: true, // 项目菜单增强:自定义别名 + 切换文件夹 + 打开文件夹 + 复制路径(含侧栏别名渲染)
57
57
  taskOrder: true, // 侧边栏会话拖动排序持久化
58
58
  fileActions: true, // 文件菜单:默认应用打开 / 打开所在目录
59
- pinnedKeepCollapsed: false, // 点击置顶会话保持项目折叠(实验性:会先展开再缩起,有闪烁)
60
59
  autoUpdatePlugins: false, // 启动时自动更新已装的 zcode-plugins 插件(含安装市场里新增的)
61
60
  sessionSwitch: true, // 会话快捷切换(alt+z 上次会话;按住 alt x/c 弹窗导航,类 alt+tab)
62
61
  titleLock: true, // 会话名锁定:手动重命名过的会话不被运行时推送的自动标题覆盖(侧栏钉回索引库中的名字)
@@ -324,6 +323,11 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
324
323
  current.devMode = body.devMode;
325
324
  }
326
325
  if (body && typeof body === 'object' && body.features && typeof body.features === 'object') {
326
+ // 会话名锁定开关的每次变化都落日志:它被意外关闭两次(页面里是关、
327
+ // 配置文件是开,来源不明),有记录才能查到是谁改的
328
+ if (typeof body.features.titleLock === 'boolean' && body.features.titleLock !== (loadConfig(configFile).features.titleLock !== false)) {
329
+ titleGenLog(dataRoot, `titleLock ${body.features.titleLock ? 'on' : 'off'}(此前为 ${!body.features.titleLock ? 'on' : 'off'})`);
330
+ }
327
331
  applyFeaturesPartial(current.features, body.features);
328
332
  }
329
333
  if (body && typeof body === 'object' && typeof body.idleReclaimMinutes === 'number' && Number.isFinite(body.idleReclaimMinutes)) {
@@ -338,6 +342,14 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
338
342
  if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
339
343
  current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
340
344
  }
345
+ // 一次性迁移标记(如别名迁移弹窗的“暂不迁移”):按项合并,只接受布尔值
346
+ if (body && typeof body === 'object' && body.migrations && typeof body.migrations === 'object') {
347
+ const patch = {};
348
+ for (const [k, v] of Object.entries(body.migrations)) {
349
+ if (typeof v === 'boolean') patch[k] = v;
350
+ }
351
+ current.migrations = { ...(current.migrations || {}), ...patch };
352
+ }
341
353
  saveConfig(configFile, current);
342
354
  json(res, 200, { ok: true, config: current });
343
355
  return;
@@ -383,6 +395,16 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
383
395
  json(res, ...writeAgents(body, agentsFile));
384
396
  return;
385
397
  }
398
+ if (req.method === 'GET' && url.pathname === '/projects-v2') {
399
+ // 虚拟项目(3.16.1 起 projects/project_roots 表):别名迁移弹窗用 roots
400
+ // 区分“目录已并入其他项目”与“项目已删除”。读不到时 available=false,
401
+ // 注入端降级为纯 DOM 检测
402
+ const data = await listVirtualProjects(dataRoot);
403
+ json(res, 200, data
404
+ ? { ok: true, available: true, ...data }
405
+ : { ok: true, available: false, projects: [], deletedRoots: [] });
406
+ return;
407
+ }
386
408
  if (req.method === 'GET' && url.pathname === '/projects') {
387
409
  const settings = readSettings(settingsFile);
388
410
  const projects = (settings.recentProjects || []).map((p) => {
@@ -1106,7 +1128,9 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1106
1128
  // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
1107
1129
  if (req.method === 'GET' && url.pathname === '/plugins/status') {
1108
1130
  await ensureZcodePluginsMarketplace();
1109
- if (Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
1131
+ // 有更新任务在执行或排队时跳过这里的节流同步(任务本身会同步市场并刷新
1132
+ // 时间戳),避免两处同时跑 marketplace update
1133
+ if (!pluginsUpdateJob && Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
1110
1134
  await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
1111
1135
  lastMarketplaceSync = Date.now();
1112
1136
  }
@@ -1120,40 +1144,8 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1120
1144
  }
1121
1145
  if (req.method === 'POST' && url.pathname === '/plugins/update') {
1122
1146
  const body = await readBody(req);
1123
- // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
1124
- const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
1125
- const steps = [];
1126
- const added = await ensureZcodePluginsMarketplace();
1127
- if (added) steps.push({ step: 'marketplace add', ok: added.ok, output: added.output || added.error });
1128
- const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
1129
- lastMarketplaceSync = Date.now();
1130
- steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
1131
- if (!sync.ok && sync.code !== 'cli-not-found') {
1132
- json(res, 500, { ok: false, code: 'plugins-update', error: sync.error || sync.output || 'marketplace update 失败', steps });
1133
- return;
1134
- }
1135
- const status = pluginUpdateStatus();
1136
- if (status) {
1137
- let updated = 0;
1138
- for (const p of status.plugins) {
1139
- if (only && p.name !== only) continue;
1140
- if (p.installed && p.hasUpdate) {
1141
- const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
1142
- steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
1143
- if (r.ok) updated += 1;
1144
- } else if (!p.installed && only) {
1145
- // 安装只走单插件入口(name 指定=各面板的安装卡):
1146
- // 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
1147
- // 未装插件一律由用户在各 tab 显式点安装
1148
- const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
1149
- steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
1150
- if (r.ok) updated += 1;
1151
- }
1152
- }
1153
- json(res, 200, { ok: steps.every((s) => s.ok), updated, steps, ...pluginUpdateStatus() });
1154
- return;
1155
- }
1156
- json(res, 200, { ok: sync.ok, updated: 0, steps });
1147
+ const result = await enqueuePluginsUpdate(body);
1148
+ json(res, result.status, result.payload);
1157
1149
  return;
1158
1150
  }
1159
1151
  json(res, 404, { ok: false, error: 'not found' });
@@ -1386,16 +1378,17 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
1386
1378
  }
1387
1379
 
1388
1380
  // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1389
- // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1390
- // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1391
- // 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
1392
- // 的各组文件算一个),上传成功后自动删除更早的
1381
+ // 写几个。aliases 不再上传——3.16.1 起别名功能已停用(官方“编辑项目”改名),
1382
+ // 且各机器项目路径不同,同步别名只会互相带来无效路径。同一份内容再写一个带
1383
+ // 时间戳的历史版本文件,供下载时任选版本恢复(旧版 zcode-pro 仍读固定名);
1384
+ // 历史版本最多保留 historyLimit 个(同一时间戳的各组文件算一个),上传成功后
1385
+ // 自动删除更早的
1393
1386
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1394
1387
  const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
1395
1388
  const docs = {};
1396
1389
  if (sync.include.zcodepro) {
1397
- // sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
1398
- const { sync: _s, version: _v, ...rest } = config;
1390
+ // sync(本机的同步配置与凭据)、version(本机 helper 版本)与 aliases 不外发
1391
+ const { sync: _s, version: _v, aliases: _a, ...rest } = config;
1399
1392
  docs.zcodepro = rest;
1400
1393
  }
1401
1394
  if (sync.include.model) {
@@ -1419,11 +1412,6 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1419
1412
  for (const kind of Object.keys(docs)) {
1420
1413
  const remote = await fetchSyncDoc(sync, proxyUrl, kind);
1421
1414
  if (remote.error) return remote;
1422
- // 别名先与云端合并:本机没配过的条目从云端带上,别让这次上传把别的机器配的别名顶掉
1423
- if (kind === 'zcodepro' && remote.doc) {
1424
- const remoteAliases = sanitizeAliases(remote.doc.data?.aliases);
1425
- if (remoteAliases) docs.zcodepro.aliases = mergeAliases(docs.zcodepro.aliases, remoteAliases);
1426
- }
1427
1415
  const doc = { app: 'zcode-pro', kind, format: 1, savedAt: stamp.toISOString(), data: docs[kind] };
1428
1416
  const docPath = join(dir, SYNC_FILES[kind]);
1429
1417
  writeFileSync(docPath, JSON.stringify(doc, null, 2), 'utf8');
@@ -1457,8 +1445,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1457
1445
  }
1458
1446
 
1459
1447
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
1460
- // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名与本机的
1461
- // 合并(合并规则见 mergeAliases);sync 配置与凭据始终保留本机的
1448
+ // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名不受影响
1449
+ //(3.16.1 起别名已停用,且云端备份里的别名多是其他机器的路径);sync 配置与
1450
+ // 凭据始终保留本机的
1462
1451
  async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
1463
1452
  // 先把勾选组的云端数据全部读到(任何一组读取失败都不动本机文件),再逐组应用
1464
1453
  const docs = {};
@@ -1476,8 +1465,9 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
1476
1465
  }
1477
1466
 
1478
1467
  // 把云端读到的各组数据落到本机(runSyncPull 与按文件的历史版本导入共用):
1479
- // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名与本机的合并,
1480
- // model 组覆盖 v2/config.json 的 provider 与 provider_config.json;覆盖前都留备份
1468
+ // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名不导入
1469
+ //(云端备份可能出自别的机器,路径在本机无效),model 组覆盖 v2/config.json 的
1470
+ // provider 与 provider_config.json;覆盖前都留备份
1481
1471
  function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
1482
1472
  const applied = [];
1483
1473
  const backupFile = (p) => {
@@ -1494,10 +1484,8 @@ function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
1494
1484
  const next = loadConfig(configFile);
1495
1485
  applyFeaturesPartial(next.features, rz.features);
1496
1486
  next.styles = applyStylesPartial(next.styles, rz.styles);
1497
- // 别名与本机的合并(本机配过的优先),不再整份替换——各机器项目路径不同,
1498
- // 合并后两边的别名都在,谁也不会被对方的同步顶掉
1499
- const remoteAliases = sanitizeAliases(rz.aliases);
1500
- if (remoteAliases) next.aliases = mergeAliases(next.aliases, remoteAliases);
1487
+ // 别名保持本机的原样:云端备份里的别名可能出自其他机器(路径在本机无效),
1488
+ // 且 3.16.1 起别名功能已停用,不再导入
1501
1489
  if (typeof rz.proxy === 'string') {
1502
1490
  const v = rz.proxy.trim();
1503
1491
  if (v === '' || /^https?:\/\/\S+:\d+$/.test(v)) next.proxy = v;
@@ -3056,7 +3044,8 @@ async function runHeadroomHook(args, timeoutMs = 30000) {
3056
3044
  });
3057
3045
  }
3058
3046
 
3059
- // 解析 ensure-proxy.sh status 输出为结构化字段(字段名以脚本 hr_status 的输出为准)。
3047
+ // 解析 ensure-proxy(.sh/.mjs) status 输出为结构化字段(字段名以脚本 hr_status 的输出为准;
3048
+ // headroom 插件 1.3.0+ 多实例模式由 hr-multi 输出同名字段,另附 instance: 行)。
3060
3049
  // 导出以便测试脚本直接驱动。
3061
3050
  export function parseHeadroomStatus(text) {
3062
3051
  const raw = String(text || '');
@@ -3066,15 +3055,23 @@ export function parseHeadroomStatus(text) {
3066
3055
  };
3067
3056
  const proxy = line('proxy') || '';
3068
3057
  const ps = line('power-save-auto-cpu') || '';
3058
+ // 多实例模式的实例行:instance: <端口> <格式> <上游> <up|down> <供应商列表>
3059
+ const instances = [];
3060
+ for (const m of raw.matchAll(/^instance: (\d+) (\S+) (\S+) (up|down) ?(.*)$/gm)) {
3061
+ instances.push({ port: Number(m[1]), format: m[2], upstream: m[3], up: m[4] === 'up', providers: m[5] || '' });
3062
+ }
3069
3063
  return {
3070
3064
  up: /^up\b/.test(proxy),
3071
3065
  port: Number(/(\d+)\)?$/.exec(proxy)?.[1]) || null,
3072
3066
  managed: line('managed-by-plugin'),
3067
+ multi: instances.length > 0 || /^instances: \d+$/m.test(raw),
3068
+ instances,
3073
3069
  backend: line('current-backend'),
3074
3070
  desiredBackend: line('desired-backend'),
3075
3071
  powerMode: line('power-mode'),
3076
3072
  saverDetect: line('saver-detect'),
3077
- watcherRunning: /^watcher: running/m.test(raw),
3073
+ // watcher 行存在才是真状态;多实例模式不输出该行时为 null(面板显示 —)
3074
+ watcherRunning: /^watcher: /m.test(raw) ? /^watcher: running/m.test(raw) : null,
3078
3075
  saverHint: (raw.split('\n').find((l) => l.startsWith('注意:')) || '').trim() || null,
3079
3076
  powerSaveMode: (/^(battery|saver|off)/.exec(ps) || [])[1] || null,
3080
3077
  watchInterval: Number(/\(interval (\d+)s\)/.exec(ps)?.[1]) || null,
@@ -3221,6 +3218,38 @@ function runZcodeCli(subArgs, timeoutMs = 120000, scope = 'plugins') {
3221
3218
  return runNodeScript([bundle, ...subArgs], timeoutMs, scope);
3222
3219
  }
3223
3220
 
3221
+ // 简单版本号比较(a<b 返回 -1,相等 0,a>b 返回 1):主版本按点分段,
3222
+ // 数字段按数值比、非数字段按字符串比;主版本相同时带 pre-release 后缀
3223
+ // (如 1.7.0-beta)低于正式版,后缀内部同样按段比较;空版本视为最旧
3224
+ // (已装版本未知时当作可更新)
3225
+ function cmpVersion(a, b) {
3226
+ const cmpSegs = (A, B) => {
3227
+ for (let i = 0; i < Math.max(A.length, B.length); i++) {
3228
+ const x = A[i] || '';
3229
+ const y = B[i] || '';
3230
+ const nx = /^\d+$/.test(x) ? Number(x) : null;
3231
+ const ny = /^\d+$/.test(y) ? Number(y) : null;
3232
+ if (nx !== null && ny !== null) {
3233
+ if (nx !== ny) return nx < ny ? -1 : 1;
3234
+ } else if (x !== y) {
3235
+ return x < y ? -1 : 1;
3236
+ }
3237
+ }
3238
+ return 0;
3239
+ };
3240
+ const parse = (v) => {
3241
+ const s = String(v || '');
3242
+ const i = s.indexOf('-');
3243
+ return { pre: i >= 0 ? s.slice(i + 1) : null, core: (i >= 0 ? s.slice(0, i) : s).split('.') };
3244
+ };
3245
+ const A = parse(a), B = parse(b);
3246
+ const c = cmpSegs(A.core, B.core);
3247
+ if (c !== 0) return c;
3248
+ if ((A.pre === null) !== (B.pre === null)) return A.pre === null ? 1 : -1;
3249
+ if (A.pre !== B.pre) return cmpSegs(A.pre.split('.'), B.pre.split('.'));
3250
+ return 0;
3251
+ }
3252
+
3224
3253
  // 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
3225
3254
  export function pluginUpdateStatus() {
3226
3255
  const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
@@ -3234,7 +3263,8 @@ export function pluginUpdateStatus() {
3234
3263
  name: e.name,
3235
3264
  latest: e.version || '',
3236
3265
  installed: mine.get(e.name) || null,
3237
- hasUpdate: mine.has(e.name) && mine.get(e.name) !== (e.version || ''),
3266
+ // 只有市场版本更高才算可更新:本地高于市场(市场回退/降级)不提示、不自动降级
3267
+ hasUpdate: mine.has(e.name) && cmpVersion(mine.get(e.name), e.version || '') < 0,
3238
3268
  }));
3239
3269
  return { plugins, updates: plugins.filter((e) => e.hasUpdate) };
3240
3270
  }
@@ -3254,6 +3284,63 @@ async function ensureZcodePluginsMarketplace() {
3254
3284
  return { ok: r.ok, output: r.output, error: r.error };
3255
3285
  }
3256
3286
 
3287
+ // /plugins/update 并发控制:启动自动更新、打开设置弹窗时的自动触发、手动按钮、
3288
+ // 各插件面板的单插件操作都打到这个端点,而 CLI 同时改同一个插件目录会互相破坏。
3289
+ // 同目标(全量或同一插件名)的并发请求共用同一次执行结果;
3290
+ // 不同目标的请求排队串行,后到的等前一个结束再跑
3291
+ let pluginsUpdateJob = null; // { only, promise }:正在执行或排队的任务
3292
+ let pluginsUpdateQueue = Promise.resolve();
3293
+
3294
+ async function runPluginsUpdateJob(body) {
3295
+ // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
3296
+ const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
3297
+ const steps = [];
3298
+ const added = await ensureZcodePluginsMarketplace();
3299
+ if (added) steps.push({ step: 'marketplace add', ok: added.ok, output: added.output || added.error });
3300
+ const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
3301
+ lastMarketplaceSync = Date.now();
3302
+ steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
3303
+ if (!sync.ok && sync.code !== 'cli-not-found') {
3304
+ return { status: 500, payload: { ok: false, code: 'plugins-update', error: sync.error || sync.output || 'marketplace update 失败', steps } };
3305
+ }
3306
+ const status = pluginUpdateStatus();
3307
+ if (!status) return { status: 200, payload: { ok: sync.ok, updated: 0, steps } };
3308
+ let updated = 0;
3309
+ for (const p of status.plugins) {
3310
+ if (only && p.name !== only) continue;
3311
+ if (p.installed && p.hasUpdate) {
3312
+ const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
3313
+ steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
3314
+ if (r.ok) updated += 1;
3315
+ } else if (!p.installed && only) {
3316
+ // 安装只走单插件入口(name 指定=各面板的安装卡):
3317
+ // 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
3318
+ // 未装插件一律由用户在各 tab 显式点安装
3319
+ const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
3320
+ steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
3321
+ if (r.ok) updated += 1;
3322
+ }
3323
+ }
3324
+ return { status: 200, payload: { ok: steps.every((s) => s.ok), updated, steps, ...pluginUpdateStatus() } };
3325
+ }
3326
+
3327
+ function enqueuePluginsUpdate(body) {
3328
+ const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
3329
+ if (pluginsUpdateJob && pluginsUpdateJob.only === only) return pluginsUpdateJob.promise;
3330
+ const prev = pluginsUpdateQueue;
3331
+ const promise = (async () => {
3332
+ await prev;
3333
+ try {
3334
+ return await runPluginsUpdateJob(body);
3335
+ } finally {
3336
+ if (pluginsUpdateJob && pluginsUpdateJob.promise === promise) pluginsUpdateJob = null;
3337
+ }
3338
+ })();
3339
+ pluginsUpdateQueue = promise.then(() => undefined, () => undefined);
3340
+ pluginsUpdateJob = { only, promise };
3341
+ return promise;
3342
+ }
3343
+
3257
3344
  // 读全局提示词(~/.zcode/AGENTS.md);文件不存在视为空内容。
3258
3345
  export function readAgents(agentsFile) {
3259
3346
  try {
package/src/host/main.mjs CHANGED
@@ -10,6 +10,7 @@ import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { resolveZcodeExecutable, resolveDataRootDir } from './paths.mjs';
13
+ import { readZcodeVersion } from './version.mjs';
13
14
  import { CdpConnection, fetchBrowserWsUrl } from './cdp.mjs';
14
15
  import { startHelper } from './helper.mjs';
15
16
  import { runSetup } from './setup.mjs';
@@ -154,12 +155,12 @@ async function waitForCdp(port, timeoutMs) {
154
155
  return false;
155
156
  }
156
157
 
157
- async function buildInjectSource(token, helperPort, features) {
158
+ async function buildInjectSource(token, helperPort, features, zcodeVersion) {
158
159
  if (!existsSync(INJECT_BUNDLE)) {
159
160
  throw new Error('缺少注入脚本 dist/inject.js,请先运行 npm run build');
160
161
  }
161
162
  const bundle = readFileSync(INJECT_BUNDLE, 'utf8');
162
- const bootstrap = `window.__ZCODEPRO__={helperUrl:'http://127.0.0.1:${helperPort}',token:${JSON.stringify(token)},features:${JSON.stringify(features)}};`;
163
+ const bootstrap = `window.__ZCODEPRO__={helperUrl:'http://127.0.0.1:${helperPort}',token:${JSON.stringify(token)},features:${JSON.stringify(features)},zcodeVersion:${JSON.stringify(zcodeVersion || '')}};`;
163
164
  return bootstrap + '\n' + bundle;
164
165
  }
165
166
 
@@ -189,6 +190,8 @@ export async function run(argv) {
189
190
  throw err;
190
191
  }
191
192
  winLog(`启动: ZCode=${exe}, CDP 端口 ${args.cdpPort}`);
193
+ const zcodeVersion = readZcodeVersion(exe);
194
+ if (args.verbose) console.log(`[zcodepro] ZCode 版本: ${zcodeVersion || '未知(app.asar 读取失败)'}`);
192
195
  const dataRoot = resolveDataRootDir();
193
196
  const token = randomBytes(16).toString('hex');
194
197
  const state = { injectedPages: 0 };
@@ -220,7 +223,7 @@ export async function run(argv) {
220
223
  }
221
224
 
222
225
  // 2. 启动辅助服务
223
- const server = await startHelper({ port: args.helperPort, token, dataRoot, state });
226
+ const server = await startHelper({ port: args.helperPort, token, dataRoot, state, zcodeVersion });
224
227
  console.log(`[zcodepro] 辅助服务已就绪: http://127.0.0.1:${args.helperPort} (数据目录 ${dataRoot})`);
225
228
 
226
229
  // 3. 连接 CDP 并注入(断线自动重连)
@@ -236,7 +239,7 @@ export async function run(argv) {
236
239
  server.close();
237
240
  process.exit(1);
238
241
  }
239
- const source = await buildInjectSource(token, args.helperPort, healthRes.features || {});
242
+ const source = await buildInjectSource(token, args.helperPort, healthRes.features || {}, zcodeVersion);
240
243
 
241
244
  async function attachConnection() {
242
245
  const conn = new CdpConnection(await fetchBrowserWsUrl(args.cdpPort));
@@ -172,6 +172,103 @@ export async function remapTaskIndexPaths(dataRoot, oldPath, newPath) {
172
172
  }
173
173
  }
174
174
 
175
+ // 虚拟项目清单(projects + project_roots,只读):别名迁移弹窗用它判断每条别名的
176
+ // 目标现状——路径是某项目的附加目录(3.16.1 多目录合并)还是已删除的项目。
177
+ // 返回 { projects: [{ name, roots: [{ path, primary }] }], deletedRoots: [path] };
178
+ // 读不到(库缺失/无驱动/查询失败)返回 null,调用方降级为纯 DOM 检测
179
+ export async function listVirtualProjects(dataRoot) {
180
+ const dbFile = taskIndexPath(dataRoot);
181
+ if (!existsSync(dbFile)) return null;
182
+ const driver = await loadDriver();
183
+ if (!driver) return null;
184
+ const SQL_ACTIVE = `SELECT p.project_id AS pid, p.name AS name, r.root_path AS path, r.is_primary, p.name_overridden
185
+ FROM projects p JOIN project_roots r ON r.project_id = p.project_id
186
+ WHERE p.deleted = 0 AND (p.host_id = 'local' OR p.host_id IS NULL)`;
187
+ const SQL_DELETED = `SELECT r.root_path AS path
188
+ FROM projects p JOIN project_roots r ON r.project_id = p.project_id
189
+ WHERE p.deleted = 1 AND (p.host_id = 'local' OR p.host_id IS NULL)`;
190
+ // CLI 驱动用制表符分隔行(项目名含引号/逗号都安全;名字含制表符的极端情况不处理)
191
+ const TAB = "char(9)";
192
+ try {
193
+ let activeRows;
194
+ let deletedRows;
195
+ if (driver.kind === 'node') {
196
+ const db = new driver.DatabaseSync(dbFile, { readOnly: true });
197
+ try {
198
+ activeRows = db.prepare(SQL_ACTIVE).all();
199
+ deletedRows = db.prepare(SQL_DELETED).all();
200
+ } finally { db.close(); }
201
+ } else {
202
+ const act = runCli(dbFile,
203
+ `SELECT p.project_id || ${TAB} || p.name || ${TAB} || r.root_path || ${TAB} || r.is_primary || ${TAB} || p.name_overridden${SQL_ACTIVE.slice(SQL_ACTIVE.indexOf(' FROM'))};`).stdout;
204
+ activeRows = act.split('\n').filter(Boolean).map((l) => {
205
+ const [pid, name, path, primary, overridden] = l.split('\t');
206
+ return { pid, name, path, primary, name_overridden: overridden };
207
+ });
208
+ const del = runCli(dbFile,
209
+ `SELECT r.root_path FROM projects p JOIN project_roots r ON r.project_id = p.project_id
210
+ WHERE p.deleted = 1 AND (p.host_id = 'local' OR p.host_id IS NULL);`).stdout;
211
+ deletedRows = del.split('\n').filter(Boolean).map((p) => ({ path: p }));
212
+ }
213
+ const byId = new Map();
214
+ for (const row of activeRows) {
215
+ const key = String(row.pid || row.name || '');
216
+ let proj = byId.get(key);
217
+ if (!proj) {
218
+ proj = { id: String(row.pid || ''), name: String(row.name || ''), roots: [], overridden: Number(row.name_overridden) === 1 };
219
+ byId.set(key, proj);
220
+ }
221
+ proj.roots.push({ path: String(row.path || ''), primary: Number(row.is_primary) === 1 });
222
+ }
223
+ // 全部出现过的任务工作区(含非项目工作区:draft/独立任务等,它们的任务
224
+ // 显示在侧栏“任务”分区):供分区头运行判定把活跃任务按归属分类
225
+ let allPaths = [];
226
+ try {
227
+ if (driver.kind === 'node') {
228
+ const db = new driver.DatabaseSync(dbFile, { readOnly: true });
229
+ try { allPaths = db.prepare('SELECT DISTINCT workspace_path FROM tasks').all().map((r) => String(r.workspace_path || '')); } finally { db.close(); }
230
+ } else {
231
+ allPaths = runCli(dbFile, 'SELECT DISTINCT workspace_path FROM tasks;').stdout.split('\n').filter(Boolean);
232
+ }
233
+ } catch { /* 读不到就不带,调用方只有项目路径 */ }
234
+ return {
235
+ projects: [...byId.values()],
236
+ deletedRoots: deletedRows.map((r) => String(r.path || '')),
237
+ allPaths,
238
+ };
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ // 批量查会话终态:task_id → task_status。分区头运行判定用它剔除官方 syncer
245
+ // 卡滞的报告(会话已完成但内存态仍报活跃时,任务行 loader 会一直卡转)。
246
+ // 读不到(库缺失/无驱动)返回 null,调用方按未确认处理
247
+ export async function listTaskStatuses(dataRoot, ids) {
248
+ const out = {};
249
+ const dbFile = taskIndexPath(dataRoot);
250
+ if (!existsSync(dbFile)) return null;
251
+ const driver = await loadDriver();
252
+ if (!driver) return null;
253
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
254
+ const inList = ids.map((id) => esc(id)).join(', ');
255
+ const sql = `SELECT task_id, task_status, updated_at FROM tasks WHERE task_id IN (${inList})`;
256
+ try {
257
+ let rows;
258
+ if (driver.kind === 'node') {
259
+ const db = new driver.DatabaseSync(dbFile, { readOnly: true });
260
+ try { rows = db.prepare(sql).all(); } finally { db.close(); }
261
+ } else {
262
+ rows = runCli(dbFile, sql + ';').stdout.split('\n').filter(Boolean)
263
+ .map((l) => { const [id, st, at] = l.split('|'); return { task_id: id, task_status: st, updated_at: at }; });
264
+ }
265
+ for (const r of rows) out[String(r.task_id)] = { status: String(r.task_status || ''), updatedAt: Number(r.updated_at) || 0 };
266
+ return out;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
175
272
  // 已重命名会话的标题表:task_id → title(title_overridden = 1 且未删除)。
176
273
  // 只读单条 SELECT,不开事务;库不存在或没有 SQLite 支持时返回空表,
177
274
  // 调用方(会话名锁定)自然降级为不干预。
@@ -0,0 +1,51 @@
1
+ // 读取已安装 ZCode 的版本号:从可执行文件旁边的 resources/app.asar(macOS 为
2
+ // Contents/Resources/app.asar)头部解析内嵌 package.json 的 version 字段。
3
+ // asar 结构:偏移 0–15 为头部尺寸描述,偏移 12 处的 u32 是头部 JSON 长度,
4
+ // JSON 紧跟在偏移 16;files["package.json"] 的 offset 相对 16+jsonLen,且实测
5
+ // 存在 4 字节对齐补位,因此多读几字节、从首个 '{' 开始截取再解析。
6
+ // 读取失败一律返回空串:调用方把空串当作“版本未知”,不启用按版本的分支。
7
+ import { closeSync, openSync, readSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ // 本启动器常以 ELECTRON_RUN_AS_NODE 方式复用 ZCode 二进制运行,该模式下 Electron
11
+ // 的 fs 仍带 asar 补丁:.asar 路径被当作归档目录而非普通文件,按字节偏移读头部会
12
+ // 全部落空(实测 3.16.1 下读到空串)。noAsar 开关(Electron 官方 API)关闭该拦截;
13
+ // 纯 Node 运行时没有这个属性,赋值无副作用
14
+ process.noAsar = true;
15
+
16
+ export function zcodeAsarPath(exePath) {
17
+ if (!exePath) return '';
18
+ // Electron 约定:Linux/Windows 资源目录在可执行文件旁;macOS 在 Contents/Resources
19
+ const dir = process.platform === 'darwin'
20
+ ? join(dirname(exePath), '..', 'Resources')
21
+ : join(dirname(exePath), 'resources');
22
+ return join(dir, 'app.asar');
23
+ }
24
+
25
+ export function readZcodeVersion(exePath) {
26
+ let fd = 0;
27
+ try {
28
+ fd = openSync(zcodeAsarPath(exePath), 'r');
29
+ const head = Buffer.alloc(16);
30
+ if (readSync(fd, head, 0, 16, 0) !== 16) return '';
31
+ const jsonLen = head.readUInt32LE(12);
32
+ // 头部异常大小说明不是预期的 asar(或文件损坏),直接放弃
33
+ if (jsonLen <= 0 || jsonLen > 64 * 1024 * 1024) return '';
34
+ const hdr = Buffer.alloc(jsonLen);
35
+ if (readSync(fd, hdr, 0, jsonLen, 16) !== jsonLen) return '';
36
+ const entry = JSON.parse(hdr.toString('utf8')).files?.['package.json'];
37
+ if (!entry || entry.unpacked || typeof entry.offset !== 'string') return '';
38
+ const base = 16 + jsonLen + Number(entry.offset);
39
+ const buf = Buffer.alloc(entry.size + 8);
40
+ readSync(fd, buf, 0, buf.length, base);
41
+ const text = buf.toString('utf8');
42
+ const start = text.indexOf('{');
43
+ if (start < 0) return '';
44
+ const version = JSON.parse(text.slice(start, start + entry.size)).version;
45
+ return typeof version === 'string' ? version : '';
46
+ } catch {
47
+ return '';
48
+ } finally {
49
+ if (fd) { try { closeSync(fd); } catch { /* ignore */ } }
50
+ }
51
+ }