zcode-pro 0.20.3 → 1.1.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.3",
3
+ "version": "1.1.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.3';
20
+ const VERSION = '1.1.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)) {
@@ -335,9 +339,26 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
335
339
  if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
336
340
  current.styles = applyStylesPartial(current.styles, body.styles);
337
341
  }
342
+ // 会话快捷切换的键位(设置 → 快捷键里修改):键名固定,值限单个字母或数字
343
+ if (body && typeof body === 'object' && body.shortcuts && typeof body.shortcuts === 'object') {
344
+ const patch = {};
345
+ for (const k of ['switchLast', 'switchPrev', 'switchNext', 'switchRecent', 'switchAttention']) {
346
+ const v = body.shortcuts[k];
347
+ if (typeof v === 'string' && /^[a-z0-9]$/.test(v)) patch[k] = v;
348
+ }
349
+ current.shortcuts = { ...(current.shortcuts || {}), ...patch };
350
+ }
338
351
  if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
339
352
  current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
340
353
  }
354
+ // 一次性迁移标记(如别名迁移弹窗的“暂不迁移”):按项合并,只接受布尔值
355
+ if (body && typeof body === 'object' && body.migrations && typeof body.migrations === 'object') {
356
+ const patch = {};
357
+ for (const [k, v] of Object.entries(body.migrations)) {
358
+ if (typeof v === 'boolean') patch[k] = v;
359
+ }
360
+ current.migrations = { ...(current.migrations || {}), ...patch };
361
+ }
341
362
  saveConfig(configFile, current);
342
363
  json(res, 200, { ok: true, config: current });
343
364
  return;
@@ -383,6 +404,16 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
383
404
  json(res, ...writeAgents(body, agentsFile));
384
405
  return;
385
406
  }
407
+ if (req.method === 'GET' && url.pathname === '/projects-v2') {
408
+ // 虚拟项目(3.16.1 起 projects/project_roots 表):别名迁移弹窗用 roots
409
+ // 区分“目录已并入其他项目”与“项目已删除”。读不到时 available=false,
410
+ // 注入端降级为纯 DOM 检测
411
+ const data = await listVirtualProjects(dataRoot);
412
+ json(res, 200, data
413
+ ? { ok: true, available: true, ...data }
414
+ : { ok: true, available: false, projects: [], deletedRoots: [] });
415
+ return;
416
+ }
386
417
  if (req.method === 'GET' && url.pathname === '/projects') {
387
418
  const settings = readSettings(settingsFile);
388
419
  const projects = (settings.recentProjects || []).map((p) => {
@@ -1106,7 +1137,9 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1106
1137
  // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
1107
1138
  if (req.method === 'GET' && url.pathname === '/plugins/status') {
1108
1139
  await ensureZcodePluginsMarketplace();
1109
- if (Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
1140
+ // 有更新任务在执行或排队时跳过这里的节流同步(任务本身会同步市场并刷新
1141
+ // 时间戳),避免两处同时跑 marketplace update
1142
+ if (!pluginsUpdateJob && Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
1110
1143
  await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
1111
1144
  lastMarketplaceSync = Date.now();
1112
1145
  }
@@ -1120,40 +1153,8 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1120
1153
  }
1121
1154
  if (req.method === 'POST' && url.pathname === '/plugins/update') {
1122
1155
  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 });
1156
+ const result = await enqueuePluginsUpdate(body);
1157
+ json(res, result.status, result.payload);
1157
1158
  return;
1158
1159
  }
1159
1160
  json(res, 404, { ok: false, error: 'not found' });
@@ -1386,16 +1387,17 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
1386
1387
  }
1387
1388
 
1388
1389
  // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1389
- // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1390
- // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1391
- // 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
1392
- // 的各组文件算一个),上传成功后自动删除更早的
1390
+ // 写几个。aliases 不再上传——3.16.1 起别名功能已停用(官方“编辑项目”改名),
1391
+ // 且各机器项目路径不同,同步别名只会互相带来无效路径。同一份内容再写一个带
1392
+ // 时间戳的历史版本文件,供下载时任选版本恢复(旧版 zcode-pro 仍读固定名);
1393
+ // 历史版本最多保留 historyLimit 个(同一时间戳的各组文件算一个),上传成功后
1394
+ // 自动删除更早的
1393
1395
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1394
1396
  const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
1395
1397
  const docs = {};
1396
1398
  if (sync.include.zcodepro) {
1397
- // sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
1398
- const { sync: _s, version: _v, ...rest } = config;
1399
+ // sync(本机的同步配置与凭据)、version(本机 helper 版本)与 aliases 不外发
1400
+ const { sync: _s, version: _v, aliases: _a, ...rest } = config;
1399
1401
  docs.zcodepro = rest;
1400
1402
  }
1401
1403
  if (sync.include.model) {
@@ -1419,11 +1421,6 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1419
1421
  for (const kind of Object.keys(docs)) {
1420
1422
  const remote = await fetchSyncDoc(sync, proxyUrl, kind);
1421
1423
  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
1424
  const doc = { app: 'zcode-pro', kind, format: 1, savedAt: stamp.toISOString(), data: docs[kind] };
1428
1425
  const docPath = join(dir, SYNC_FILES[kind]);
1429
1426
  writeFileSync(docPath, JSON.stringify(doc, null, 2), 'utf8');
@@ -1457,8 +1454,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1457
1454
  }
1458
1455
 
1459
1456
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
1460
- // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名与本机的
1461
- // 合并(合并规则见 mergeAliases);sync 配置与凭据始终保留本机的
1457
+ // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名不受影响
1458
+ //(3.16.1 起别名已停用,且云端备份里的别名多是其他机器的路径);sync 配置与
1459
+ // 凭据始终保留本机的
1462
1460
  async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
1463
1461
  // 先把勾选组的云端数据全部读到(任何一组读取失败都不动本机文件),再逐组应用
1464
1462
  const docs = {};
@@ -1476,8 +1474,9 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
1476
1474
  }
1477
1475
 
1478
1476
  // 把云端读到的各组数据落到本机(runSyncPull 与按文件的历史版本导入共用):
1479
- // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名与本机的合并,
1480
- // model 组覆盖 v2/config.json 的 provider 与 provider_config.json;覆盖前都留备份
1477
+ // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名不导入
1478
+ //(云端备份可能出自别的机器,路径在本机无效),model 组覆盖 v2/config.json 的
1479
+ // provider 与 provider_config.json;覆盖前都留备份
1481
1480
  function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
1482
1481
  const applied = [];
1483
1482
  const backupFile = (p) => {
@@ -1494,10 +1493,8 @@ function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
1494
1493
  const next = loadConfig(configFile);
1495
1494
  applyFeaturesPartial(next.features, rz.features);
1496
1495
  next.styles = applyStylesPartial(next.styles, rz.styles);
1497
- // 别名与本机的合并(本机配过的优先),不再整份替换——各机器项目路径不同,
1498
- // 合并后两边的别名都在,谁也不会被对方的同步顶掉
1499
- const remoteAliases = sanitizeAliases(rz.aliases);
1500
- if (remoteAliases) next.aliases = mergeAliases(next.aliases, remoteAliases);
1496
+ // 别名保持本机的原样:云端备份里的别名可能出自其他机器(路径在本机无效),
1497
+ // 且 3.16.1 起别名功能已停用,不再导入
1501
1498
  if (typeof rz.proxy === 'string') {
1502
1499
  const v = rz.proxy.trim();
1503
1500
  if (v === '' || /^https?:\/\/\S+:\d+$/.test(v)) next.proxy = v;
@@ -3056,7 +3053,8 @@ async function runHeadroomHook(args, timeoutMs = 30000) {
3056
3053
  });
3057
3054
  }
3058
3055
 
3059
- // 解析 ensure-proxy.sh status 输出为结构化字段(字段名以脚本 hr_status 的输出为准)。
3056
+ // 解析 ensure-proxy(.sh/.mjs) status 输出为结构化字段(字段名以脚本 hr_status 的输出为准;
3057
+ // headroom 插件 1.3.0+ 多实例模式由 hr-multi 输出同名字段,另附 instance: 行)。
3060
3058
  // 导出以便测试脚本直接驱动。
3061
3059
  export function parseHeadroomStatus(text) {
3062
3060
  const raw = String(text || '');
@@ -3066,15 +3064,23 @@ export function parseHeadroomStatus(text) {
3066
3064
  };
3067
3065
  const proxy = line('proxy') || '';
3068
3066
  const ps = line('power-save-auto-cpu') || '';
3067
+ // 多实例模式的实例行:instance: <端口> <格式> <上游> <up|down> <供应商列表>
3068
+ const instances = [];
3069
+ for (const m of raw.matchAll(/^instance: (\d+) (\S+) (\S+) (up|down) ?(.*)$/gm)) {
3070
+ instances.push({ port: Number(m[1]), format: m[2], upstream: m[3], up: m[4] === 'up', providers: m[5] || '' });
3071
+ }
3069
3072
  return {
3070
3073
  up: /^up\b/.test(proxy),
3071
3074
  port: Number(/(\d+)\)?$/.exec(proxy)?.[1]) || null,
3072
3075
  managed: line('managed-by-plugin'),
3076
+ multi: instances.length > 0 || /^instances: \d+$/m.test(raw),
3077
+ instances,
3073
3078
  backend: line('current-backend'),
3074
3079
  desiredBackend: line('desired-backend'),
3075
3080
  powerMode: line('power-mode'),
3076
3081
  saverDetect: line('saver-detect'),
3077
- watcherRunning: /^watcher: running/m.test(raw),
3082
+ // watcher 行存在才是真状态;多实例模式不输出该行时为 null(面板显示 —)
3083
+ watcherRunning: /^watcher: /m.test(raw) ? /^watcher: running/m.test(raw) : null,
3078
3084
  saverHint: (raw.split('\n').find((l) => l.startsWith('注意:')) || '').trim() || null,
3079
3085
  powerSaveMode: (/^(battery|saver|off)/.exec(ps) || [])[1] || null,
3080
3086
  watchInterval: Number(/\(interval (\d+)s\)/.exec(ps)?.[1]) || null,
@@ -3221,6 +3227,38 @@ function runZcodeCli(subArgs, timeoutMs = 120000, scope = 'plugins') {
3221
3227
  return runNodeScript([bundle, ...subArgs], timeoutMs, scope);
3222
3228
  }
3223
3229
 
3230
+ // 简单版本号比较(a<b 返回 -1,相等 0,a>b 返回 1):主版本按点分段,
3231
+ // 数字段按数值比、非数字段按字符串比;主版本相同时带 pre-release 后缀
3232
+ // (如 1.7.0-beta)低于正式版,后缀内部同样按段比较;空版本视为最旧
3233
+ // (已装版本未知时当作可更新)
3234
+ function cmpVersion(a, b) {
3235
+ const cmpSegs = (A, B) => {
3236
+ for (let i = 0; i < Math.max(A.length, B.length); i++) {
3237
+ const x = A[i] || '';
3238
+ const y = B[i] || '';
3239
+ const nx = /^\d+$/.test(x) ? Number(x) : null;
3240
+ const ny = /^\d+$/.test(y) ? Number(y) : null;
3241
+ if (nx !== null && ny !== null) {
3242
+ if (nx !== ny) return nx < ny ? -1 : 1;
3243
+ } else if (x !== y) {
3244
+ return x < y ? -1 : 1;
3245
+ }
3246
+ }
3247
+ return 0;
3248
+ };
3249
+ const parse = (v) => {
3250
+ const s = String(v || '');
3251
+ const i = s.indexOf('-');
3252
+ return { pre: i >= 0 ? s.slice(i + 1) : null, core: (i >= 0 ? s.slice(0, i) : s).split('.') };
3253
+ };
3254
+ const A = parse(a), B = parse(b);
3255
+ const c = cmpSegs(A.core, B.core);
3256
+ if (c !== 0) return c;
3257
+ if ((A.pre === null) !== (B.pre === null)) return A.pre === null ? 1 : -1;
3258
+ if (A.pre !== B.pre) return cmpSegs(A.pre.split('.'), B.pre.split('.'));
3259
+ return 0;
3260
+ }
3261
+
3224
3262
  // 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
3225
3263
  export function pluginUpdateStatus() {
3226
3264
  const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
@@ -3234,7 +3272,8 @@ export function pluginUpdateStatus() {
3234
3272
  name: e.name,
3235
3273
  latest: e.version || '',
3236
3274
  installed: mine.get(e.name) || null,
3237
- hasUpdate: mine.has(e.name) && mine.get(e.name) !== (e.version || ''),
3275
+ // 只有市场版本更高才算可更新:本地高于市场(市场回退/降级)不提示、不自动降级
3276
+ hasUpdate: mine.has(e.name) && cmpVersion(mine.get(e.name), e.version || '') < 0,
3238
3277
  }));
3239
3278
  return { plugins, updates: plugins.filter((e) => e.hasUpdate) };
3240
3279
  }
@@ -3254,6 +3293,63 @@ async function ensureZcodePluginsMarketplace() {
3254
3293
  return { ok: r.ok, output: r.output, error: r.error };
3255
3294
  }
3256
3295
 
3296
+ // /plugins/update 并发控制:启动自动更新、打开设置弹窗时的自动触发、手动按钮、
3297
+ // 各插件面板的单插件操作都打到这个端点,而 CLI 同时改同一个插件目录会互相破坏。
3298
+ // 同目标(全量或同一插件名)的并发请求共用同一次执行结果;
3299
+ // 不同目标的请求排队串行,后到的等前一个结束再跑
3300
+ let pluginsUpdateJob = null; // { only, promise }:正在执行或排队的任务
3301
+ let pluginsUpdateQueue = Promise.resolve();
3302
+
3303
+ async function runPluginsUpdateJob(body) {
3304
+ // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
3305
+ const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
3306
+ const steps = [];
3307
+ const added = await ensureZcodePluginsMarketplace();
3308
+ if (added) steps.push({ step: 'marketplace add', ok: added.ok, output: added.output || added.error });
3309
+ const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
3310
+ lastMarketplaceSync = Date.now();
3311
+ steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
3312
+ if (!sync.ok && sync.code !== 'cli-not-found') {
3313
+ return { status: 500, payload: { ok: false, code: 'plugins-update', error: sync.error || sync.output || 'marketplace update 失败', steps } };
3314
+ }
3315
+ const status = pluginUpdateStatus();
3316
+ if (!status) return { status: 200, payload: { ok: sync.ok, updated: 0, steps } };
3317
+ let updated = 0;
3318
+ for (const p of status.plugins) {
3319
+ if (only && p.name !== only) continue;
3320
+ if (p.installed && p.hasUpdate) {
3321
+ const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
3322
+ steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
3323
+ if (r.ok) updated += 1;
3324
+ } else if (!p.installed && only) {
3325
+ // 安装只走单插件入口(name 指定=各面板的安装卡):
3326
+ // 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
3327
+ // 未装插件一律由用户在各 tab 显式点安装
3328
+ const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
3329
+ steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
3330
+ if (r.ok) updated += 1;
3331
+ }
3332
+ }
3333
+ return { status: 200, payload: { ok: steps.every((s) => s.ok), updated, steps, ...pluginUpdateStatus() } };
3334
+ }
3335
+
3336
+ function enqueuePluginsUpdate(body) {
3337
+ const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
3338
+ if (pluginsUpdateJob && pluginsUpdateJob.only === only) return pluginsUpdateJob.promise;
3339
+ const prev = pluginsUpdateQueue;
3340
+ const promise = (async () => {
3341
+ await prev;
3342
+ try {
3343
+ return await runPluginsUpdateJob(body);
3344
+ } finally {
3345
+ if (pluginsUpdateJob && pluginsUpdateJob.promise === promise) pluginsUpdateJob = null;
3346
+ }
3347
+ })();
3348
+ pluginsUpdateQueue = promise.then(() => undefined, () => undefined);
3349
+ pluginsUpdateJob = { only, promise };
3350
+ return promise;
3351
+ }
3352
+
3257
3353
  // 读全局提示词(~/.zcode/AGENTS.md);文件不存在视为空内容。
3258
3354
  export function readAgents(agentsFile) {
3259
3355
  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
+ }