zcode-pro 0.18.0 → 0.20.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/src/host/main.mjs CHANGED
@@ -17,11 +17,15 @@ import { runSetup } from './setup.mjs';
17
17
  const here = dirname(fileURLToPath(import.meta.url));
18
18
  const INJECT_BUNDLE = resolve(here, '..', '..', 'dist', 'inject.js');
19
19
 
20
- // Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/PowerShell 的默认
21
- // 代码页(936/GBK)按 GBK 解码会显示乱码。检测到真实控制台且非 UTF-8 代码页时
22
- // 切到 65001——只影响当前控制台会话(重开终端即恢复),重定向/非交互不干预。
20
+ // Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/cmd 的默认代码页
21
+ //(936/GBK)按 GBK 解码会显示乱码。检测到非 UTF-8 代码页时切到 65001——只影响
22
+ // 当前控制台会话(重开终端即恢复)。不能用 stdout.isTTY 单独作前提:npm 全局
23
+ // 入口(bin/zcode-pro.mjs)spawn ZCode 运行时跑 cli.mjs,子进程 stdout 经
24
+ // stdio:'inherit' 是管道(isTTY=false),但控制台乱码照样发生(0.18/0.19 用户
25
+ // 实拍);此时 stdin 仍连着终端,故用 stdout 或 stdin 任一 isTTY 判定。真正的
26
+ // 重定向场景(stdout 与 stdin 都不是 TTY)不干预。
23
27
  // 放在模块顶层:cli.mjs 经 import main.mjs 进入,setup 等子命令的输出同样受益。
24
- if (process.platform === 'win32' && process.stdout.isTTY) {
28
+ if (process.platform === 'win32' && (process.stdout.isTTY || process.stdin.isTTY)) {
25
29
  try {
26
30
  const cp = execSync('chcp.com', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).match(/(\d+)/)?.[1];
27
31
  if (cp && cp !== '65001') execSync('chcp.com 65001', { stdio: 'ignore', windowsHide: true });
@@ -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,11 +206,140 @@ 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
+
231
+ // 智能起名写入:把生成标题按“手动重命名”存入索引(title_overridden = 1),
232
+ // 之后官方自动标题不会再覆盖这条记录。仅在会话未删除、且从未被手动重命名过
233
+ // (title_overridden = 0)时写入——手动重命名永远优先。返回 { written, title };
234
+ // written = false 表示没写(已重命名或会话不在索引中)。库被占用时抛 index-busy。
235
+ export async function setTaskTitleIfNotOverridden(dataRoot, sessId, title) {
236
+ const dbFile = taskIndexPath(dataRoot);
237
+ if (!existsSync(dbFile)) return { written: false, title };
238
+ const driver = await loadDriver();
239
+ if (!driver) {
240
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
241
+ err.code = 'index-error';
242
+ throw err;
243
+ }
244
+ try {
245
+ if (driver.kind === 'node') {
246
+ const db = new driver.DatabaseSync(dbFile);
247
+ try {
248
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
249
+ db.exec('BEGIN IMMEDIATE');
250
+ try {
251
+ const r = db.prepare(
252
+ 'UPDATE tasks SET title = ?, title_overridden = 1 WHERE task_id = ? AND deleted = 0 AND title_overridden = 0'
253
+ ).run(title, sessId);
254
+ db.exec('COMMIT');
255
+ return { written: r.changes > 0, title };
256
+ } catch (err) {
257
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
258
+ throw err;
259
+ }
260
+ } finally { db.close(); }
261
+ }
262
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
263
+ const r = runCli(dbFile,
264
+ 'BEGIN IMMEDIATE;\n'
265
+ + `UPDATE tasks SET title = ${esc(title)}, title_overridden = 1`
266
+ + ` WHERE task_id = ${esc(sessId)} AND deleted = 0 AND title_overridden = 0;\n`
267
+ + 'SELECT changes();\nCOMMIT;');
268
+ return { written: Number(r.stdout.trim()) > 0, title };
269
+ } catch (err) {
270
+ if (isBusyError(err)) {
271
+ const e = new Error('任务索引正被占用(ZCode 可能正在写入)');
272
+ e.code = 'index-busy';
273
+ throw e;
274
+ }
275
+ const e = new Error('写入任务标题失败: ' + (err?.message || err));
276
+ e.code = 'index-error';
277
+ throw e;
278
+ }
279
+ }
280
+
209
281
  function isBusyError(err) {
210
282
  const s = String(err?.code || '') + String(err?.message || '');
211
283
  return /SQLITE_BUSY|database is locked/i.test(s);
212
284
  }
213
285
 
286
+ // 读某会话记录的模型信息(智能起名“跟随当前会话”用)。model 列存
287
+ // “<供应商id>/<模型id>”(如 be0a90cb…/glm-5.3),拆开后返回
288
+ // { providerId, modelId };没有斜线时 providerId 退回 provider 列的值。
289
+ // 会话不存在返回 null;读取失败按 null 处理(调用方换明确报错)
290
+ export async function getTaskProviderModel(dataRoot, sessId) {
291
+ const driver = await loadDriver();
292
+ if (!driver) return null;
293
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
294
+
295
+ // 优先读 CLI 会话库的 model_usage:首个 turn 开始时即写入,比任务索引的
296
+ // model 列早十几秒(任务索引由桌面同步器延迟写;新会话头十几秒里它还是
297
+ // 空值,起名第一次调用会因此解析到不存在的模型,实测 400 模型不存在)
298
+ const usageFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
299
+ if (existsSync(usageFile)) {
300
+ try {
301
+ if (driver.kind === 'node') {
302
+ const db = new driver.DatabaseSync(usageFile, { readOnly: true });
303
+ try {
304
+ const r = db.prepare(
305
+ "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"
306
+ ).get(sessId);
307
+ if (r && r.model_id) return { providerId: r.provider_id, modelId: r.model_id };
308
+ } finally { db.close(); }
309
+ } else {
310
+ const r = runCli(usageFile,
311
+ `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);`);
312
+ const parsed = JSON.parse(r.stdout.trim() || 'null');
313
+ if (parsed && parsed.model_id) return { providerId: parsed.provider_id, modelId: parsed.model_id };
314
+ }
315
+ } catch { /* 表不存在(旧版 CLI)等:走任务索引兜底 */ }
316
+ }
317
+
318
+ const dbFile = taskIndexPath(dataRoot);
319
+ if (!existsSync(dbFile)) return null;
320
+ let row = null;
321
+ try {
322
+ if (driver.kind === 'node') {
323
+ const db = new driver.DatabaseSync(dbFile);
324
+ try {
325
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
326
+ row = db.prepare('SELECT provider, model FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId) || null;
327
+ } finally { db.close(); }
328
+ } else {
329
+ const r = runCli(dbFile,
330
+ `SELECT coalesce(json_object('provider', provider, 'model', model), 'null') FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
331
+ const parsed = JSON.parse((r.stdout.trim() || 'null'));
332
+ row = parsed && typeof parsed === 'object' ? parsed : null;
333
+ }
334
+ } catch { /* 读不了就当没有 */ }
335
+ if (!row) return null;
336
+ const model = String(row.model || '');
337
+ const slash = model.indexOf('/');
338
+ return slash > 0
339
+ ? { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) }
340
+ : { providerId: String(row.provider || ''), modelId: model };
341
+ }
342
+
214
343
  // 任务(会话)ID → 工作区路径(无尾分隔符):空闲回收在 Windows 上的后台命令
215
344
  // 归属判定用(命令行里只带会话 ID,需经索引反查它属于哪个项目)。
216
345
  // 读取失败返回空表,调用方按“无法归属”保守处理
@@ -253,3 +382,132 @@ function runCli(dbFile, script) {
253
382
  if (r.status !== 0) throw new Error('sqlite3 CLI 执行失败: ' + (r.stderr || r.stdout || ('exit ' + r.status)));
254
383
  return r;
255
384
  }
385
+
386
+ // CLI 会话库(~/.zcode/cli/db/db.sqlite)的会话标题权威写入:title + title_source=
387
+ // 'custom'(官方 setCustomSessionTitle 同款语义)。custom 后官方运行时彻底知情:
388
+ // 自动起名写回后的自动校正:桌面端打开会话时会把运行时内存缓存的旧标题写回
389
+ // 任务索引(实测点开会话的瞬间发生,运行时不知道我们直写过 CLI 库),索引里的
390
+ // 标题因此被改回旧值。CLI 库的 custom 标题才是权威——对“我们写过、且 CLI 标题
391
+ // 仍等于我们所写”的会话,索引标题若不同就改回 CLI 值。只认自己写过的记录:
392
+ // 官方手动改名(renameTask)两库同步更新不会分歧;其 v4 同步失败时索引比 CLI 新
393
+ // (历史会话场景),不认记录就绝不会误回滚用户的手动改名。
394
+ // writtenMap: { sessId → { title, at } };返回本次校正的会话数(失败静默,下次再试)
395
+ export async function reconcileCustomTitles(dataRoot, writtenMap) {
396
+ const repaired = [];
397
+ if (!writtenMap || typeof writtenMap !== 'object') return repaired;
398
+ const driver = await loadDriver();
399
+ if (!driver) return repaired;
400
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
401
+ const cliFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
402
+ const idxFile = taskIndexPath(dataRoot);
403
+ if (!existsSync(cliFile) || !existsSync(idxFile)) return repaired;
404
+
405
+ const readCli = async (sessId) => {
406
+ if (driver.kind === 'node') {
407
+ const db = new driver.DatabaseSync(cliFile, { readOnly: true });
408
+ try {
409
+ return db.prepare("SELECT title, title_source FROM session WHERE id = ?").get(sessId) || null;
410
+ } finally { db.close(); }
411
+ }
412
+ const r = runCli(cliFile, `SELECT coalesce(json_object('title', title, 'title_source', title_source), 'null') FROM session WHERE id = ${esc(sessId)};`);
413
+ const parsed = JSON.parse(r.stdout.trim() || 'null');
414
+ return parsed && typeof parsed === 'object' ? parsed : null;
415
+ };
416
+
417
+ for (const [sessId, rec] of Object.entries(writtenMap)) {
418
+ if (!/^sess_[A-Za-z0-9._-]+$/.test(sessId) || !rec || typeof rec.title !== 'string') continue;
419
+ let cli;
420
+ try { cli = await readCli(sessId); } catch { continue; }
421
+ // CLI 已不是我们所写的 custom 标题:别人改过(手动改名/官方),不碰
422
+ if (!cli || cli.title_source !== 'custom' || cli.title !== rec.title) continue;
423
+ try {
424
+ if (driver.kind === 'node') {
425
+ const db = new driver.DatabaseSync(idxFile);
426
+ try {
427
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
428
+ db.exec('BEGIN IMMEDIATE');
429
+ try {
430
+ const r = db.prepare(
431
+ 'UPDATE tasks SET title = ? WHERE task_id = ? AND deleted = 0 AND title_overridden = 1 AND title != ?'
432
+ ).run(rec.title, sessId, rec.title);
433
+ db.exec('COMMIT');
434
+ if (r.changes > 0) repaired.push(sessId);
435
+ } catch (err) {
436
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚 */ }
437
+ throw err;
438
+ }
439
+ } finally { db.close(); }
440
+ } else {
441
+ const r = runCli(idxFile,
442
+ 'BEGIN IMMEDIATE;\n'
443
+ + `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`
444
+ + 'SELECT changes();\nCOMMIT;');
445
+ if (Number(r.stdout.trim()) > 0) repaired.push(sessId);
446
+ }
447
+ } catch { /* 占用等:下次再校正 */ }
448
+ }
449
+ return repaired;
450
+ }
451
+
452
+ // 自动起名在写回前重读时看到 custom 会放弃(session-title.ts 的 custom_title 短路),
453
+ // 桌面同步器(zcodeTaskIndexSyncer)会把 custom 标题带着 titleOverridden=1 写回
454
+ // 任务索引——单写任务索引被官方排队的自动标题覆盖的竞态(实测 14 秒后覆盖)由此
455
+ // 消除。time_title_updated/time_updated 与官方 updateSession 一致(title 变更即刷新,
456
+ // time_updated 只增不减);title_message_id 保留原值。旧版 CLI 库没有 title_source
457
+ // 列时返回 skipped(缺 custom 语义,写了也守不住,退回单写任务索引的旧行为)。
458
+ // 库被占用时抛 index-busy(与任务索引写入同款重试语义)。
459
+ export async function setCustomSessionTitle(dataRoot, sessId, title) {
460
+ const dbFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
461
+ if (!existsSync(dbFile)) return { written: false, skipped: 'no-cli-db' };
462
+ const driver = await loadDriver();
463
+ if (!driver) {
464
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
465
+ err.code = 'index-error';
466
+ throw err;
467
+ }
468
+ try {
469
+ if (driver.kind === 'node') {
470
+ const db = new driver.DatabaseSync(dbFile);
471
+ try {
472
+ const cols = db.prepare("PRAGMA table_info('session')").all();
473
+ if (!cols.some((c) => c.name === 'title_source')) return { written: false, skipped: 'no-title-source' };
474
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
475
+ db.exec('BEGIN IMMEDIATE');
476
+ try {
477
+ const now = Date.now();
478
+ const r = db.prepare(
479
+ "UPDATE session SET title = ?, title_source = 'custom', time_title_updated = ?, time_updated = max(time_updated, ?) WHERE id = ?"
480
+ ).run(title, now, now, sessId);
481
+ db.exec('COMMIT');
482
+ return { written: r.changes > 0, skipped: r.changes > 0 ? '' : 'no-session-row' };
483
+ } catch (err) {
484
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
485
+ throw err;
486
+ }
487
+ } finally {
488
+ db.close();
489
+ }
490
+ }
491
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
492
+ const cols = runCli(dbFile, "PRAGMA table_info('session');").stdout;
493
+ if (!/title_source/.test(cols)) return { written: false, skipped: 'no-title-source' };
494
+ const now = Date.now();
495
+ const r = runCli(dbFile,
496
+ 'BEGIN IMMEDIATE;\n'
497
+ + `UPDATE session SET title = ${esc(title)}, title_source = 'custom',`
498
+ + ` time_title_updated = ${now}, time_updated = max(time_updated, ${now})`
499
+ + ` WHERE id = ${esc(sessId)};\n`
500
+ + 'SELECT changes();\nCOMMIT;');
501
+ const changed = Number(r.stdout.trim()) > 0;
502
+ return { written: changed, skipped: changed ? '' : 'no-session-row' };
503
+ } catch (err) {
504
+ if (isBusyError(err)) {
505
+ const e = new Error('CLI 会话库正被占用(ZCode 可能正在写入)');
506
+ e.code = 'index-busy';
507
+ throw e;
508
+ }
509
+ const e = new Error('写入 CLI 会话标题失败: ' + (err?.message || err));
510
+ e.code = 'index-error';
511
+ throw e;
512
+ }
513
+ }