zcode-pro 0.19.0 → 0.20.1

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.
@@ -1,5 +1,5 @@
1
1
  // zcode-pro setup:本机集成设置入口(npm / pip 等包管理器安装不写系统目录,
2
- // 需要菜单入口等集成时显式运行本子命令)。按「设置项」组织(id / 标题 /
2
+ // 需要菜单入口等集成时显式运行本子命令)。按“设置项”组织(id / 标题 /
3
3
  // install / uninstall),以后新增设置项往 items 里加即可。
4
4
  // setup 不依赖 ZCode 运行,在 main.run 里先于 ZCode 定位处理。
5
5
  //
@@ -291,17 +291,17 @@ function plistContent(hasIcon) {
291
291
 
292
292
  const shortcutItem = {
293
293
  id: 'desktop-shortcut',
294
- title: '快捷方式“ZCode Pro”(应用菜单/开始菜单,图标复用 ZCode)',
294
+ title: 'Shortcut "ZCode Pro" (app/start menu, icon reuses ZCode)',
295
295
  supported: (plat) => SUPPORTED.includes(plat),
296
296
 
297
297
  install(plat, opts = {}) {
298
298
  const cmd = commandInPath(plat);
299
299
  if (!cmd) {
300
- throw new Error('未在 PATH 中找到 zcode-pro 命令,无法创建快捷方式;请确认安装方式后重试。');
300
+ throw new Error('zcode-pro command not found in PATH, cannot create shortcut; check your install and retry');
301
301
  }
302
302
  if (!sameInstall(cmd, plat)) {
303
- throw new Error(`PATH 中的 zcode-pro(${cmd})不属于当前运行的这份安装,` +
304
- '为避免快捷方式指向旧版本已跳过;请用那份安装自己的方式管理快捷方式。');
303
+ throw new Error(`the zcode-pro in PATH (${cmd}) is not part of this installation, ` +
304
+ 'skipped so the shortcut will not point at an old version; manage shortcuts with that installation instead');
305
305
  }
306
306
  if (plat === 'linux') return this.installLinux(cmd, opts);
307
307
  if (plat === 'win32') return this.installWindows(cmd, opts);
@@ -312,7 +312,7 @@ const shortcutItem = {
312
312
  // 系统级(AUR 等)已有同名条目时不再建用户级:desktop 文件 id 相同时用户级
313
313
  // 会遮蔽系统级(项目既有约定),残留后表现为菜单一直运行另一份
314
314
  if (existsSync('/usr/share/applications/zcode-pro.desktop')) {
315
- return { skipped: '检测到系统级 ZCode Pro 快捷方式(AUR 等已安装),用户级条目会遮蔽它,已跳过' };
315
+ return { skipped: 'system-level shortcut detected (e.g. AUR); a user entry would shadow it, skipped' };
316
316
  }
317
317
  const appsDir = applicationsDir();
318
318
  const target = join(appsDir, DESKTOP_FILE);
@@ -320,21 +320,21 @@ const shortcutItem = {
320
320
  if (existsSync(target)) {
321
321
  const cur = readFileSync(target, 'utf-8');
322
322
  if (!cur.includes(SETUP_MARK)) {
323
- return { skipped: `${target} 已存在且非 setup 创建(如 install.sh),未覆盖` };
323
+ return { skipped: `${target} exists and was not created by setup (e.g. install.sh), not overwritten` };
324
324
  }
325
325
  // 早期版本写过 0644 的条目:即使内容无需重写也补上执行位(原因见 writeDesktopEntry)
326
326
  chmodSync(target, 0o755);
327
327
  const exec = (cur.match(/^Exec=(.+)$/m) || [])[1] || '';
328
328
  if (exec.includes(cmd)) {
329
- parts.push('应用菜单条目已存在且指向当前命令,无需重复创建');
329
+ parts.push('app-menu entry already exists and points to the current command, nothing to do');
330
330
  } else {
331
331
  writeDesktopEntry(target, cmd);
332
- parts.push(`已更新 ${target}(Exec=${cmd})`);
332
+ parts.push(`updated ${target} (Exec=${cmd})`);
333
333
  }
334
334
  } else {
335
335
  mkdirSync(appsDir, { recursive: true });
336
336
  writeDesktopEntry(target, cmd);
337
- parts.push(`已写入 ${target}(Exec=${cmd})`);
337
+ parts.push(`written ${target} (Exec=${cmd})`);
338
338
  }
339
339
  try { execFileSync('update-desktop-database', [appsDir], { stdio: 'ignore' }); } catch { /* 无该命令时忽略 */ }
340
340
  // 桌面条目独立判断(不因菜单条目幂等而短路)
@@ -347,13 +347,13 @@ const shortcutItem = {
347
347
  // GNOME 的桌面(Nautilus)额外要求 trusted 元数据才显示“允许启动”;
348
348
  // 其他环境无 gio / 写不进元数据时忽略
349
349
  try { execFileSync('gio', ['set', dTarget, 'metadata::trusted', 'true'], { stdio: 'ignore' }); } catch { /* 非 GNOME 忽略 */ }
350
- parts.push(`桌面 ${dTarget}`);
350
+ parts.push(`desktop ${dTarget}`);
351
351
  } else {
352
- parts.push('桌面已存在非 setup 创建的条目,未覆盖');
352
+ parts.push('desktop entry exists and was not created by setup, not overwritten');
353
353
  }
354
354
  }
355
- if (parts.length === 1 && parts[0].startsWith('应用菜单条目已存在')) return { skipped: parts[0] };
356
- return { detail: parts.join(';') };
355
+ if (parts.length === 1 && parts[0].startsWith('app-menu entry already exists')) return { skipped: parts[0] };
356
+ return { detail: parts.join('; ') };
357
357
  },
358
358
 
359
359
  installWindows(cmd, opts = {}) {
@@ -367,9 +367,9 @@ const shortcutItem = {
367
367
  const plan = windowsShortcutPlan(cmd, !!opts.desktop, wshOk, !menuForeign);
368
368
  if (menuForeign && (!opts.desktop || deskForeign)) {
369
369
  return { skipped: [
370
- menuForeign ? `已存在其他方式创建的 ${plan.lnkPath},未覆盖` : '',
371
- deskForeign ? `桌面 ${deskPath} 已存在其他方式创建的条目,未覆盖` : '',
372
- ].filter(Boolean).join(';') };
370
+ menuForeign ? `${plan.lnkPath} exists (created elsewhere), not overwritten` : '',
371
+ deskForeign ? `desktop ${deskPath} exists (created elsewhere), not overwritten` : '',
372
+ ].filter(Boolean).join('; ') };
373
373
  }
374
374
  if (wshOk) {
375
375
  mkdirSync(plan.vbsDir, { recursive: true });
@@ -377,14 +377,14 @@ const shortcutItem = {
377
377
  }
378
378
  runPowerShell(plan.psScript);
379
379
  const how = wshOk
380
- ? `经 ${plan.vbsPath} 静默启动`
381
- : '本机 WSH 异常,经 cmd 最小化启动(启动时会短暂闪烁控制台窗口)';
380
+ ? `silent launch via ${plan.vbsPath}`
381
+ : 'WSH unavailable on this machine; minimized cmd launch (console flashes briefly on start)';
382
382
  const parts = [];
383
383
  parts.push(menuForeign
384
- ? `开始菜单 ${plan.lnkPath} 已存在其他方式创建的条目,未覆盖`
385
- : `已创建 ${plan.lnkPath}`);
386
- if (opts.desktop) parts.push(deskForeign ? `桌面 ${deskPath} 已存在其他方式创建的条目,未覆盖` : '已创建桌面快捷方式');
387
- return { detail: `${parts.join(';')}(${how})` };
384
+ ? `start menu ${plan.lnkPath} exists (created elsewhere), not overwritten`
385
+ : `created ${plan.lnkPath}`);
386
+ if (opts.desktop) parts.push(deskForeign ? `desktop ${deskPath} exists (created elsewhere), not overwritten` : 'desktop shortcut created');
387
+ return { detail: `${parts.join('; ')} (${how})` };
388
388
  },
389
389
 
390
390
  installMac(cmd, opts = {}) {
@@ -392,7 +392,7 @@ const shortcutItem = {
392
392
  const contents = join(app, 'Contents');
393
393
  const marker = join(contents, 'zcodepro-setup-marker');
394
394
  if (existsSync(app) && !existsSync(marker)) {
395
- return { skipped: `${app} 已存在且非 setup 创建,未覆盖` };
395
+ return { skipped: `${app} exists and was not created by setup, not overwritten` };
396
396
  }
397
397
  let iconCopied = false;
398
398
  for (const src of [join('/', 'Applications', 'ZCode.app', 'Contents', 'Resources', 'app.icns'),
@@ -411,15 +411,15 @@ const shortcutItem = {
411
411
  chmodSync(launcher, 0o755);
412
412
  writeFileSync(join(contents, 'Info.plist'), plistContent(iconCopied));
413
413
  writeFileSync(marker, '');
414
- let detail = `已创建 ${app}(启动器指向 ${cmd}${iconCopied ? ',图标已复制' : ''})`;
414
+ let detail = `created ${app} (launcher -> ${cmd}${iconCopied ? ', icon copied' : ''})`;
415
415
  if (opts.desktop) {
416
416
  const link = macDesktopLink();
417
417
  if (existsSync(link)) {
418
- detail += ';桌面已存在同名入口,未覆盖';
418
+ detail += '; desktop entry with the same name exists, not overwritten';
419
419
  } else {
420
420
  try {
421
421
  symlinkSync(app, link);
422
- detail += `;桌面 ${link}`;
422
+ detail += `; desktop ${link}`;
423
423
  } catch { /* 建链接失败仅省略桌面入口 */ }
424
424
  }
425
425
  }
@@ -445,16 +445,16 @@ const shortcutItem = {
445
445
  rmSync(dTarget);
446
446
  removed.push(dTarget);
447
447
  }
448
- if (!removed.length) return { skipped: '未发现 setup 创建的快捷方式' };
448
+ if (!removed.length) return { skipped: 'no setup-created shortcuts found' };
449
449
  try { execFileSync('update-desktop-database', [applicationsDir()], { stdio: 'ignore' }); } catch { /* 无该命令时忽略 */ }
450
- return { detail: `已移除 ${removed.join('、')}` };
450
+ return { detail: `removed ${removed.join(', ')}` };
451
451
  },
452
452
 
453
453
  uninstallWindows() {
454
454
  const paths = windowsPaths();
455
455
  const hadVbs = existsSync(paths.vbsPath);
456
456
  const hadLnk = existsSync(paths.lnkPath);
457
- if (!hadVbs && !hadLnk) return { skipped: '未发现 setup 创建的快捷方式' };
457
+ if (!hadVbs && !hadLnk) return { skipped: 'no setup-created shortcuts found' };
458
458
  // .lnk 可能已被 install.ps1 等重建并指向它自己:只有 Arguments 带本功能归属
459
459
  // 标记才删;校验失败也保留 .lnk(宁可漏删不误删)
460
460
  let lnkRemoved = false;
@@ -488,29 +488,29 @@ const shortcutItem = {
488
488
  vbsCleaned = true;
489
489
  } catch {
490
490
  vbsCleaned = !existsSync(paths.vbsPath);
491
- if (!vbsCleaned) console.error(` (${paths.vbsDir} 删除失败,可能被占用,可稍后手动删除)`);
491
+ if (!vbsCleaned) console.error(` (${paths.vbsDir} could not be deleted (may be in use); remove it manually later)`);
492
492
  }
493
493
  }
494
494
  }
495
495
  const parts = [];
496
496
  if (hadLnk) {
497
- parts.push(lnkRemoved ? '开始菜单/桌面快捷方式' : verified ? '快捷方式已非本功能创建,未删除' : '快捷方式归属校验失败,未删除 .lnk');
497
+ parts.push(lnkRemoved ? 'start-menu/desktop shortcut' : verified ? 'shortcut not created by setup, kept' : 'shortcut ownership check failed, .lnk kept');
498
498
  }
499
- if (hadVbs) parts.push(vbsCleaned ? '生成的启动脚本' : '启动脚本(删除失败)');
500
- return { detail: `已移除:${parts.join(';')}` };
499
+ if (hadVbs) parts.push(vbsCleaned ? 'generated launcher script' : 'launcher script (delete failed)');
500
+ return { detail: `removed: ${parts.join(', ')}` };
501
501
  },
502
502
 
503
503
  uninstallMac() {
504
504
  const app = macAppBundle();
505
505
  const marker = join(app, 'Contents', 'zcodepro-setup-marker');
506
- if (!existsSync(marker)) return { skipped: '未发现 setup 创建的应用包' };
506
+ if (!existsSync(marker)) return { skipped: 'no setup-created app bundle found' };
507
507
  // setup --desktop 建的桌面入口一并清理(只删指向本应用包的链接)
508
508
  const link = macDesktopLink();
509
509
  try {
510
510
  if (existsSync(link) && realpathSync(link) === realpathSync(app)) rmSync(link);
511
511
  } catch { /* 链接失效则忽略 */ }
512
512
  rmSync(app, { recursive: true, force: true });
513
- return { detail: `已移除 ${app}` };
513
+ return { detail: `removed ${app}` };
514
514
  },
515
515
  };
516
516
 
@@ -532,22 +532,25 @@ function rmSyncWithRetry(target, tries = 4) {
532
532
  const items = [shortcutItem];
533
533
 
534
534
  export async function runSetup({ uninstall = false, platform: plat = platform(), desktop = false } = {}) {
535
- console.log(`[zcodepro] setup ${uninstall ? '卸载' : '安装'}本机集成${desktop && !uninstall ? '(含桌面)' : ''}:`);
535
+ // 输出全用 ASCII 英文:setup 常在 GBK 代码页的 cmd/PowerShell 里跑,中文经
536
+ // 管道输出时极易乱码(chcp 适配也不是所有入口都来得及生效);ASCII 文案
537
+ // 在任何代码页下都稳定显示
538
+ console.log(`[zcodepro] setup ${uninstall ? 'remove' : 'install'} local integration${desktop && !uninstall ? ' (with desktop)' : ''}:`);
536
539
  let failed = 0;
537
540
  for (const item of items) {
538
541
  if (!item.supported(plat)) {
539
- console.log(` · ${item.title}:当前平台(${plat})暂不支持,跳过`);
542
+ console.log(` - ${item.title}: not supported on ${plat}, skipped`);
540
543
  continue;
541
544
  }
542
545
  try {
543
546
  const res = uninstall ? item.uninstall(plat) : item.install(plat, { desktop });
544
- if (res && res.skipped) console.log(` · ${item.title}:${res.skipped}`);
545
- else console.log(` ✓ ${item.title}:${res && res.detail ? res.detail : '完成'}`);
547
+ if (res && res.skipped) console.log(` - ${item.title}: ${res.skipped}`);
548
+ else console.log(` [OK] ${item.title}: ${res && res.detail ? res.detail : 'done'}`);
546
549
  } catch (err) {
547
550
  failed++;
548
- console.error(` ✗ ${item.title}:${err?.message || err}`);
551
+ console.error(` [FAIL] ${item.title}: ${err?.message || err}`);
549
552
  }
550
553
  }
551
- console.log(uninstall ? '' : ' 撤销:zcode-pro setup --uninstall');
554
+ console.log(uninstall ? '' : ' undo: zcode-pro setup --uninstall');
552
555
  if (failed) process.exitCode = 1;
553
556
  }
@@ -206,6 +206,28 @@ export async function listOverriddenTitles(dataRoot) {
206
206
  return { titles };
207
207
  }
208
208
 
209
+ // 智能起名的前置判定:会话是否已被手动重命名(或已被起名写过)。双写前先查它,
210
+ // 已重命名就整体跳过——避免起名晚到的写入盖掉用户的 custom 标题。读取失败按
211
+ // 未重命名处理(后续写入自带原子守卫兜底)
212
+ export async function isTaskTitleOverridden(dataRoot, sessId) {
213
+ const dbFile = taskIndexPath(dataRoot);
214
+ if (!existsSync(dbFile)) return false;
215
+ const driver = await loadDriver();
216
+ if (!driver) return false;
217
+ try {
218
+ if (driver.kind === 'node') {
219
+ const db = new driver.DatabaseSync(dbFile, { readOnly: true });
220
+ try {
221
+ const r = db.prepare('SELECT title_overridden FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId);
222
+ return r?.title_overridden === 1;
223
+ } finally { db.close(); }
224
+ }
225
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
226
+ const r = runCli(dbFile, `SELECT title_overridden FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
227
+ return /^1/m.test(r.stdout);
228
+ } catch { return false; }
229
+ }
230
+
209
231
  // 智能起名写入:把生成标题按“手动重命名”存入索引(title_overridden = 1),
210
232
  // 之后官方自动标题不会再覆盖这条记录。仅在会话未删除、且从未被手动重命名过
211
233
  // (title_overridden = 0)时写入——手动重命名永远优先。返回 { written, title };
@@ -264,24 +286,53 @@ function isBusyError(err) {
264
286
  // 读某会话记录的模型信息(智能起名“跟随当前会话”用)。model 列存
265
287
  // “<供应商id>/<模型id>”(如 be0a90cb…/glm-5.3),拆开后返回
266
288
  // { providerId, modelId };没有斜线时 providerId 退回 provider 列的值。
267
- // 会话不存在返回 null;读取失败按 null 处理(调用方换明确报错)
289
+ // 会话不存在返回 null;读取失败按 null 处理(调用方换明确报错)。
290
+ // sessId 为空 = 取最近更新的会话(设置面板的测试按钮没有会话上下文,
291
+ // 与视觉代理的测试同款语义)
268
292
  export async function getTaskProviderModel(dataRoot, sessId) {
269
- const dbFile = taskIndexPath(dataRoot);
270
- if (!existsSync(dbFile)) return null;
271
293
  const driver = await loadDriver();
272
294
  if (!driver) return null;
295
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
296
+
297
+ // 优先读 CLI 会话库的 model_usage:首个 turn 开始时即写入,比任务索引的
298
+ // model 列早十几秒(任务索引由桌面同步器延迟写;新会话头十几秒里它还是
299
+ // 空值,起名第一次调用会因此解析到不存在的模型,实测 400 模型不存在)
300
+ const usageFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
301
+ if (existsSync(usageFile) && sessId) {
302
+ try {
303
+ if (driver.kind === 'node') {
304
+ const db = new driver.DatabaseSync(usageFile, { readOnly: true });
305
+ try {
306
+ const r = db.prepare(
307
+ "SELECT provider_id, model_id FROM model_usage WHERE session_id = ? AND provider_id IS NOT NULL AND provider_id != '' ORDER BY started_at DESC LIMIT 1"
308
+ ).get(sessId);
309
+ if (r && r.model_id) return { providerId: r.provider_id, modelId: r.model_id };
310
+ } finally { db.close(); }
311
+ } else {
312
+ const r = runCli(usageFile,
313
+ `SELECT coalesce(json_object('provider_id', provider_id, 'model_id', model_id), 'null') FROM (SELECT provider_id, model_id FROM model_usage WHERE session_id = ${esc(sessId)} AND provider_id IS NOT NULL AND provider_id != '' ORDER BY started_at DESC LIMIT 1);`);
314
+ const parsed = JSON.parse(r.stdout.trim() || 'null');
315
+ if (parsed && parsed.model_id) return { providerId: parsed.provider_id, modelId: parsed.model_id };
316
+ }
317
+ } catch { /* 表不存在(旧版 CLI)等:走任务索引兜底 */ }
318
+ }
319
+
320
+ const dbFile = taskIndexPath(dataRoot);
321
+ if (!existsSync(dbFile)) return null;
273
322
  let row = null;
323
+ const where = sessId
324
+ ? `WHERE task_id = ${esc(sessId)} AND deleted = 0`
325
+ : 'WHERE deleted = 0 AND model != \'\' ORDER BY updated_at DESC LIMIT 1';
274
326
  try {
275
327
  if (driver.kind === 'node') {
276
328
  const db = new driver.DatabaseSync(dbFile);
277
329
  try {
278
330
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
279
- row = db.prepare('SELECT provider, model FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId) || null;
331
+ row = db.prepare(`SELECT provider, model FROM tasks ${where}`).get() || null;
280
332
  } finally { db.close(); }
281
333
  } else {
282
- const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
283
334
  const r = runCli(dbFile,
284
- `SELECT coalesce(json_object('provider', provider, 'model', model), 'null') FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
335
+ `SELECT coalesce(json_object('provider', provider, 'model', model), 'null') FROM (SELECT provider, model FROM tasks ${where});`);
285
336
  const parsed = JSON.parse((r.stdout.trim() || 'null'));
286
337
  row = parsed && typeof parsed === 'object' ? parsed : null;
287
338
  }
@@ -336,3 +387,132 @@ function runCli(dbFile, script) {
336
387
  if (r.status !== 0) throw new Error('sqlite3 CLI 执行失败: ' + (r.stderr || r.stdout || ('exit ' + r.status)));
337
388
  return r;
338
389
  }
390
+
391
+ // CLI 会话库(~/.zcode/cli/db/db.sqlite)的会话标题权威写入:title + title_source=
392
+ // 'custom'(官方 setCustomSessionTitle 同款语义)。custom 后官方运行时彻底知情:
393
+ // 自动起名写回后的自动校正:桌面端打开会话时会把运行时内存缓存的旧标题写回
394
+ // 任务索引(实测点开会话的瞬间发生,运行时不知道我们直写过 CLI 库),索引里的
395
+ // 标题因此被改回旧值。CLI 库的 custom 标题才是权威——对“我们写过、且 CLI 标题
396
+ // 仍等于我们所写”的会话,索引标题若不同就改回 CLI 值。只认自己写过的记录:
397
+ // 官方手动改名(renameTask)两库同步更新不会分歧;其 v4 同步失败时索引比 CLI 新
398
+ // (历史会话场景),不认记录就绝不会误回滚用户的手动改名。
399
+ // writtenMap: { sessId → { title, at } };返回本次校正的会话数(失败静默,下次再试)
400
+ export async function reconcileCustomTitles(dataRoot, writtenMap) {
401
+ const repaired = [];
402
+ if (!writtenMap || typeof writtenMap !== 'object') return repaired;
403
+ const driver = await loadDriver();
404
+ if (!driver) return repaired;
405
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
406
+ const cliFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
407
+ const idxFile = taskIndexPath(dataRoot);
408
+ if (!existsSync(cliFile) || !existsSync(idxFile)) return repaired;
409
+
410
+ const readCli = async (sessId) => {
411
+ if (driver.kind === 'node') {
412
+ const db = new driver.DatabaseSync(cliFile, { readOnly: true });
413
+ try {
414
+ return db.prepare("SELECT title, title_source FROM session WHERE id = ?").get(sessId) || null;
415
+ } finally { db.close(); }
416
+ }
417
+ const r = runCli(cliFile, `SELECT coalesce(json_object('title', title, 'title_source', title_source), 'null') FROM session WHERE id = ${esc(sessId)};`);
418
+ const parsed = JSON.parse(r.stdout.trim() || 'null');
419
+ return parsed && typeof parsed === 'object' ? parsed : null;
420
+ };
421
+
422
+ for (const [sessId, rec] of Object.entries(writtenMap)) {
423
+ if (!/^sess_[A-Za-z0-9._-]+$/.test(sessId) || !rec || typeof rec.title !== 'string') continue;
424
+ let cli;
425
+ try { cli = await readCli(sessId); } catch { continue; }
426
+ // CLI 已不是我们所写的 custom 标题:别人改过(手动改名/官方),不碰
427
+ if (!cli || cli.title_source !== 'custom' || cli.title !== rec.title) continue;
428
+ try {
429
+ if (driver.kind === 'node') {
430
+ const db = new driver.DatabaseSync(idxFile);
431
+ try {
432
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
433
+ db.exec('BEGIN IMMEDIATE');
434
+ try {
435
+ const r = db.prepare(
436
+ 'UPDATE tasks SET title = ? WHERE task_id = ? AND deleted = 0 AND title_overridden = 1 AND title != ?'
437
+ ).run(rec.title, sessId, rec.title);
438
+ db.exec('COMMIT');
439
+ if (r.changes > 0) repaired.push(sessId);
440
+ } catch (err) {
441
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚 */ }
442
+ throw err;
443
+ }
444
+ } finally { db.close(); }
445
+ } else {
446
+ const r = runCli(idxFile,
447
+ 'BEGIN IMMEDIATE;\n'
448
+ + `UPDATE tasks SET title = ${esc(rec.title)} WHERE task_id = ${esc(sessId)} AND deleted = 0 AND title_overridden = 1 AND title != ${esc(rec.title)};\n`
449
+ + 'SELECT changes();\nCOMMIT;');
450
+ if (Number(r.stdout.trim()) > 0) repaired.push(sessId);
451
+ }
452
+ } catch { /* 占用等:下次再校正 */ }
453
+ }
454
+ return repaired;
455
+ }
456
+
457
+ // 自动起名在写回前重读时看到 custom 会放弃(session-title.ts 的 custom_title 短路),
458
+ // 桌面同步器(zcodeTaskIndexSyncer)会把 custom 标题带着 titleOverridden=1 写回
459
+ // 任务索引——单写任务索引被官方排队的自动标题覆盖的竞态(实测 14 秒后覆盖)由此
460
+ // 消除。time_title_updated/time_updated 与官方 updateSession 一致(title 变更即刷新,
461
+ // time_updated 只增不减);title_message_id 保留原值。旧版 CLI 库没有 title_source
462
+ // 列时返回 skipped(缺 custom 语义,写了也守不住,退回单写任务索引的旧行为)。
463
+ // 库被占用时抛 index-busy(与任务索引写入同款重试语义)。
464
+ export async function setCustomSessionTitle(dataRoot, sessId, title) {
465
+ const dbFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
466
+ if (!existsSync(dbFile)) return { written: false, skipped: 'no-cli-db' };
467
+ const driver = await loadDriver();
468
+ if (!driver) {
469
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
470
+ err.code = 'index-error';
471
+ throw err;
472
+ }
473
+ try {
474
+ if (driver.kind === 'node') {
475
+ const db = new driver.DatabaseSync(dbFile);
476
+ try {
477
+ const cols = db.prepare("PRAGMA table_info('session')").all();
478
+ if (!cols.some((c) => c.name === 'title_source')) return { written: false, skipped: 'no-title-source' };
479
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
480
+ db.exec('BEGIN IMMEDIATE');
481
+ try {
482
+ const now = Date.now();
483
+ const r = db.prepare(
484
+ "UPDATE session SET title = ?, title_source = 'custom', time_title_updated = ?, time_updated = max(time_updated, ?) WHERE id = ?"
485
+ ).run(title, now, now, sessId);
486
+ db.exec('COMMIT');
487
+ return { written: r.changes > 0, skipped: r.changes > 0 ? '' : 'no-session-row' };
488
+ } catch (err) {
489
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
490
+ throw err;
491
+ }
492
+ } finally {
493
+ db.close();
494
+ }
495
+ }
496
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
497
+ const cols = runCli(dbFile, "PRAGMA table_info('session');").stdout;
498
+ if (!/title_source/.test(cols)) return { written: false, skipped: 'no-title-source' };
499
+ const now = Date.now();
500
+ const r = runCli(dbFile,
501
+ 'BEGIN IMMEDIATE;\n'
502
+ + `UPDATE session SET title = ${esc(title)}, title_source = 'custom',`
503
+ + ` time_title_updated = ${now}, time_updated = max(time_updated, ${now})`
504
+ + ` WHERE id = ${esc(sessId)};\n`
505
+ + 'SELECT changes();\nCOMMIT;');
506
+ const changed = Number(r.stdout.trim()) > 0;
507
+ return { written: changed, skipped: changed ? '' : 'no-session-row' };
508
+ } catch (err) {
509
+ if (isBusyError(err)) {
510
+ const e = new Error('CLI 会话库正被占用(ZCode 可能正在写入)');
511
+ e.code = 'index-busy';
512
+ throw e;
513
+ }
514
+ const e = new Error('写入 CLI 会话标题失败: ' + (err?.message || err));
515
+ e.code = 'index-error';
516
+ throw e;
517
+ }
518
+ }
@@ -67,12 +67,17 @@ const zh = {
67
67
  proxyScopeSyncDesc: 'WebDAV 上传与下载走代理。默认关闭:坚果云等国内网盘直连即可,代理停了同步会跟着失败。',
68
68
  tabSync: '同步',
69
69
  tabDebug: '调试',
70
- syncDesc: '手动把 ZCode Pro 设置与模型设置备份到 WebDAV,或在新电脑上恢复。两项各自独立成文件,可只上传或只覆盖其中一项;每次上传会额外保留一份带时间戳的历史版本,下载时任选一个版本恢复;仅在点击按钮时传输,不做自动同步。',
70
+ testNoSessionHint: '选择“跟随当前会话[当前模型]”时,选择上次会话中使用的供应商[模型]',
71
+ syncDesc: '手动把 ZCode Pro 设置与模型设置备份到 WebDAV,或在新电脑上恢复。两项各自独立成文件,可只上传或只覆盖其中一项;每次上传会额外保留一份带时间戳的历史版本(保留多少个见“历史版本”一行,上传后自动删除超出的最旧版本),下载时任选一个版本恢复;仅在点击按钮时传输,不做自动同步。',
71
72
  syncUrlPlaceholder: 'https://dav.jianguoyun.com/dav/',
72
73
  syncDir: '上传目录',
73
74
  syncDirPlaceholder: 'zcode-pro',
74
75
  syncLogin: '登录',
75
76
  syncPassword: '密码',
77
+ syncHistoryLimit: '历史版本',
78
+ syncHistoryLimitUnit: '个',
79
+ syncHistoryLimitHint: '云端最多保留最近几次上传留下的历史版本,上传成功后自动删除更早的;固定名最新版不受影响;0 = 不保留历史版本',
80
+ syncPruned: '已清理 {n} 个更早的历史版本',
76
81
  syncSave: '保存',
77
82
  syncSaved: '同步配置已保存',
78
83
  syncFormHint: '同步文件存到“上传目录”(默认 zcode-pro,可多级,自动创建):zcodepro.json 与 model.json 始终是最新一版,旁边另保留带时间戳的历史版本。',
@@ -93,8 +98,8 @@ const zh = {
93
98
  syncConfirmTitle: '从云端下载',
94
99
  syncConfirmDesc: '将用所选文件覆盖本机对应的一项(覆盖前会在原文件旁留下 .zcodepro-backup 备份);本机的同步配置与登录凭据不受影响。模型设置恢复后建议重启 ZCode 生效。确认导入?',
95
100
  syncConfirmGo: '确认导入',
96
- syncPickDesc: '固定名文件始终是最新一版,带时间戳的是每次上传留下的历史版本;「导入」用所选文件覆盖本机对应的一项。',
97
- syncVerDeleteLatestNote: '注意:这是“最新”文件,旧版 zcode-pro 的「从云端下载」依赖它,删除后旧版机器将无法下载;带时间戳的历史版本不受影响。',
101
+ syncPickDesc: '固定名文件始终是最新一版,带时间戳的是每次上传留下的历史版本;“导入”用所选文件覆盖本机对应的一项。',
102
+ syncVerDeleteLatestNote: '注意:这是“最新”文件,旧版 zcode-pro 的“从云端下载”依赖它,删除后旧版机器将无法下载;带时间戳的历史版本不受影响。',
98
103
  syncHint: '同步登录凭据仅保存在本机 zcodepro.json;模型设置包含自定义供应商的 API Key,只会上传到你自己的 WebDAV。',
99
104
  syncTest: '测试',
100
105
  syncTesting: '测试中…',
@@ -118,8 +123,8 @@ const zh = {
118
123
  syncVerDeleted: '已删除云端备份',
119
124
  syncVerSaved: '已保存到',
120
125
  devMode: '开发模式',
121
- devModeRowDesc: '开启后出现「重载 ZCode Pro」按钮;也可给启动器设环境变量 ZCODEPRO_DEV=1 启用。此标签页仅在开发模式生效时显示。',
122
- devModeDesc: '开发调试用:改完 zcode-pro 代码后不必重启 ZCode,点「重载 ZCode Pro」即可加载新代码。流程为自动构建(需在源码目录运行)→ 以新进程重启 ZCode Pro(本地助手与注入脚本都换新)→ 所有 ZCode 窗口自动刷新并重新注入;ZCode 本体与已打开的会话不受影响。',
126
+ devModeRowDesc: '开启后出现“重载 ZCode Pro”按钮;也可给启动器设环境变量 ZCODEPRO_DEV=1 启用。此标签页仅在开发模式生效时显示。',
127
+ devModeDesc: '开发调试用:改完 zcode-pro 代码后不必重启 ZCode,点“重载 ZCode Pro”即可加载新代码。流程为自动构建(需在源码目录运行)→ 以新进程重启 ZCode Pro(本地助手与注入脚本都换新)→ 所有 ZCode 窗口自动刷新并重新注入;ZCode 本体与已打开的会话不受影响。',
123
128
  devReload: '重载 ZCode Pro',
124
129
  devReloading: '重载中…',
125
130
  devReloadDesc: '点击后约 2-3 秒内所有 ZCode 窗口自动刷新一次并加载新脚本。',
@@ -140,7 +145,7 @@ const zh = {
140
145
  lineHeightName: '回答行高',
141
146
  lineHeightDesc: '回答正文的行高(倍数)。',
142
147
  thinkingLineHeightName: '思考行高',
143
- thinkingLineHeightDesc: '「已工作/工作中」过程记录(读取/编辑/查阅/终端条目与思考内容)的行高(倍数)。',
148
+ thinkingLineHeightDesc: '“已工作/工作中”过程记录(读取/编辑/查阅/终端条目与思考内容)的行高(倍数)。',
144
149
  userLineHeightName: '提问行高',
145
150
  userLineHeightDesc: '提问内容的行高(倍数)。',
146
151
  contentWidthName: '内容宽度',
@@ -258,11 +263,10 @@ const zh = {
258
263
  visionSkipMinutes: '跳过分钟数',
259
264
  visionSkipMinutesHint: '跳过多久后自动恢复尝试;期间任一次成功即清零计数',
260
265
  visionResetChain: '恢复默认链',
261
- visionResetChainConfirm: '将链模式与代理列表恢复为默认两级(glm-session → glm-flash),当前对代理的修改会丢失。继续?',
262
- visionBaseUrl: '接口地址 baseUrl',
266
+ visionResetChainConfirm: '将链模式与代理列表恢复为默认单级(session:跟随会话与其当前模型),当前对代理的修改会丢失。继续?',
267
+ visionBaseUrl: '接口地址',
263
268
  visionModel: '模型',
264
269
  visionApiKey: 'API Key',
265
- visionApiKeyHint: '留空自动取 GLM 订阅 key',
266
270
  visionPrompt: '识别提示词(pipeline 后续步可用 {prev} 引用上一步)',
267
271
  visionTest: '测试',
268
272
  visionTesting: '测试中…',
@@ -429,12 +433,17 @@ const en = {
429
433
  proxyScopeSyncDesc: 'WebDAV upload and download go through the proxy. Off by default: domestic WebDAV providers work fine direct, and a stopped proxy breaks sync.',
430
434
  tabSync: 'Sync',
431
435
  tabDebug: 'Debug',
432
- syncDesc: 'Manually back up ZCode Pro settings and model settings to WebDAV, or restore them on a new computer. The two items are stored as separate files, so you can upload or overwrite either one alone; every upload also keeps a timestamped history version and download lets you pick any of them. Data moves only when you click, never automatically.',
436
+ testNoSessionHint: 'With “follow current session [current model]” selected, the provider [model] of the last session is used.',
437
+ syncDesc: 'Manually back up ZCode Pro settings and model settings to WebDAV, or restore them on a new computer. The two items are stored as separate files, so you can upload or overwrite either one alone; every upload also keeps a timestamped history version (how many to keep is set in the "History" row; older ones are removed automatically after upload) and download lets you pick any of them. Data moves only when you click, never automatically.',
433
438
  syncUrlPlaceholder: 'https://dav.example.com/dav/',
434
- syncDir: '上传目录',
439
+ syncDir: 'Folder',
435
440
  syncDirPlaceholder: 'zcode-pro',
436
441
  syncLogin: 'Login',
437
442
  syncPassword: 'Password',
443
+ syncHistoryLimit: 'History',
444
+ syncHistoryLimitUnit: 'versions',
445
+ syncHistoryLimitHint: 'How many recent uploads keep their history versions on the server; older ones are removed automatically after a successful upload. The fixed-name latest file is never removed. 0 = keep no history versions.',
446
+ syncPruned: 'removed {n} older history versions',
438
447
  syncSave: 'Save',
439
448
  syncSaved: 'Sync settings saved.',
440
449
  syncFormHint: 'Sync files go inside the folder (default zcode-pro, multi-level allowed, created automatically): zcodepro.json and model.json are always the latest version; timestamped history versions are kept alongside.',
@@ -620,11 +629,10 @@ const en = {
620
629
  visionSkipMinutes: 'Skip minutes',
621
630
  visionSkipMinutesHint: 'How long a skipped proxy rests before being retried; any success resets the counter',
622
631
  visionResetChain: 'Reset default chain',
623
- visionResetChainConfirm: 'Reset chain mode and the proxy list to the default 2-level chain (glm-session → glm-flash)? Current proxy edits will be lost. Continue?',
624
- visionBaseUrl: 'Base URL',
632
+ visionResetChainConfirm: 'Reset chain mode and the proxy list to the default single-level chain (session: follow session and its current model)? Current proxy edits will be lost. Continue?',
633
+ visionBaseUrl: 'API URL',
625
634
  visionModel: 'Model',
626
635
  visionApiKey: 'API Key',
627
- visionApiKeyHint: 'Leave empty to reuse the GLM subscription key',
628
636
  visionPrompt: 'Recognition prompt ({prev} = previous step in pipeline)',
629
637
  visionTest: 'Test',
630
638
  visionTesting: 'Testing…',
@@ -895,7 +903,7 @@ export function observeRadixPopups(onPopup) {
895
903
  }
896
904
  // 兜底:直接观察 role=menu 的 content。延迟一帧再回调——Radix 偶尔分两帧渲染
897
905
  // (先挂 menu 容器、下一帧才填菜单项),立即回调时菜单项为空,调用方找不到
898
- // 锚点项就永远漏注入(菜单项填入不会再产生 role=menu 节点添加事件)
906
+ // 参照的菜单项就永远漏注入(菜单项填入不会再产生 role=menu 节点添加事件)
899
907
  if (node.getAttribute && node.getAttribute('role') === 'menu' && !seen.has(node)) {
900
908
  seen.add(node);
901
909
  requestAnimationFrame(() => {