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.
@@ -4,19 +4,20 @@
4
4
 
5
5
  import { createServer } from 'node:http';
6
6
  import { execFile, spawn, spawnSync } from 'node:child_process';
7
- import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { createDecipheriv, createHash } from 'node:crypto';
8
+ import { appendFileSync, chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
9
  import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
9
10
  import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
10
- import { homedir, tmpdir } from 'node:os';
11
+ import { homedir, tmpdir, userInfo } from 'node:os';
11
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
13
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
13
- import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, getTaskProviderModel } from './taskIndex.mjs';
14
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel } from './taskIndex.mjs';
14
15
  import { pickFolderSystem } from './pickFolder.mjs';
15
16
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
16
17
  import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
17
18
  import { resolveZcodeExecutable } from './paths.mjs';
18
19
 
19
- const VERSION = '0.19.0';
20
+ const VERSION = '0.20.1';
20
21
 
21
22
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
22
23
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -104,6 +105,7 @@ export function defaultConfig() {
104
105
  //(用会话当前模型);prompt 留空 = 内置默认提示词
105
106
  titleGen: {
106
107
  enabled: false,
108
+ customWritten: {},
107
109
  provider: 'session',
108
110
  model: 'glm-5.3-flash',
109
111
  prompt: '',
@@ -118,14 +120,16 @@ export function defaultConfig() {
118
120
  // 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
119
121
  // 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
120
122
  proxyScopes: { plugins: true, deps: true, sync: false },
121
- // 开发模式(设置弹窗“调试”标签页):开启后出现「重载」按钮,可在不重启
123
+ // 开发模式(设置弹窗“调试”标签页):开启后出现“重载”按钮,可在不重启
122
124
  // ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
123
125
  devMode: false,
124
126
  // 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
125
127
  // 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
126
128
  // url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
127
129
  // 写进网盘根目录);include 选择同步内容;凭据只存本机、不同步出去;
128
- // lastPushAt/lastPullAt 只记录本机操作时间。
130
+ // lastPushAt/lastPullAt 只记录本机操作时间;historyLimit 为云端历史版本保留
131
+ // 个数(同一次上传的各组文件算一个,上传成功后自动删除更早的;0 = 不留历史,
132
+ // 只有固定名最新版)。
129
133
  sync: {
130
134
  type: 'webdav',
131
135
  url: '',
@@ -133,6 +137,7 @@ export function defaultConfig() {
133
137
  login: '',
134
138
  password: '',
135
139
  include: { zcodepro: true, model: true },
140
+ historyLimit: 10,
136
141
  lastPushAt: null,
137
142
  lastPullAt: null,
138
143
  },
@@ -246,6 +251,20 @@ function proxyUrlFor(scope) {
246
251
  return activeProxyScopes[scope] ? activeProxyUrl : '';
247
252
  }
248
253
 
254
+ // 智能起名诊断日志:追加到 <dataRoot>/zcodepro.log(与启动诊断同文件),超 512KB
255
+ // 轮转 .old。起名过程注入侧静默(失败不打扰用户),出问题时靠这里回查
256
+ let titleGenLogRotated = false;
257
+ function titleGenLog(dataRoot, line) {
258
+ try {
259
+ const file = join(dataRoot, 'zcodepro.log');
260
+ if (!titleGenLogRotated && existsSync(file) && statSync(file).size > 512 * 1024) {
261
+ renameSync(file, file + '.old');
262
+ titleGenLogRotated = true;
263
+ }
264
+ appendFileSync(file, `[${new Date().toISOString()}] title-gen ${line}\n`);
265
+ } catch { /* 日志失败不影响功能 */ }
266
+ }
267
+
249
268
  export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
250
269
  const configFile = join(dataRoot, 'zcodepro.json');
251
270
  const settingsFile = join(dataRoot, 'v2', 'setting.json');
@@ -418,6 +437,13 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
418
437
  // 注入侧据此把被自动标题覆盖的侧栏行钉回重命名值;响应带开关,
419
438
  // 注入侧按需拉取这一个端点即可(无轮询)
420
439
  if (req.method === 'GET' && url.pathname === '/pinned-titles') {
440
+ // 先自动校正:桌面端打开会话时会把运行时缓存的旧标题写回任务索引(实测),
441
+ // 对我们写过且 CLI custom 仍是所写标题的会话,把索引改回来再下发
442
+ const config0 = loadConfig(configFile);
443
+ if (config0.titleGen.customWritten && Object.keys(config0.titleGen.customWritten).length) {
444
+ const repaired = await reconcileCustomTitles(dataRoot, config0.titleGen.customWritten);
445
+ if (repaired.length) titleGenLog(dataRoot, `reconciled sess=${repaired.map((s) => s.slice(5, 13)).join(',')}`);
446
+ }
421
447
  const r = await listOverriddenTitles(dataRoot);
422
448
  if (r.error) {
423
449
  json(res, 500, { ok: false, code: r.code, error: r.error });
@@ -488,9 +514,43 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
488
514
  return;
489
515
  }
490
516
  const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
491
- const title = await generateTitleWith(cfg, text, dataRoot, sessId);
517
+ let title;
518
+ try {
519
+ title = await generateTitleWith(cfg, text, dataRoot, sessId);
520
+ } catch (err) {
521
+ titleGenLog(dataRoot, `generate-failed sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
522
+ throw err; // 交给外层统一 500
523
+ }
492
524
  let written = false;
525
+ let store = ''; // CLI 会话库写入结果:'' 成功 | skipped 原因 | 'error'
493
526
  if (sessId && body.test !== true) {
527
+ // 已被手动重命名(或前次起名已写过)就整体跳过——手动重命名永远优先,
528
+ // 也避免起名晚到的 CLI 写入盖掉用户的 custom 标题
529
+ if (await isTaskTitleOverridden(dataRoot, sessId)) {
530
+ json(res, 200, { ok: true, title, written: false, store: 'skipped: overridden' });
531
+ return;
532
+ }
533
+ // 双写,顺序不能反:先写 CLI 会话库(title_source=custom,权威层——
534
+ // 官方自动起名看到 custom 会放弃,桌面同步器会把 custom 标题带着
535
+ // overridden=1 送回任务索引),再写任务索引(展示层,立即上屏)。
536
+ // 只写任务索引时,官方运行时排队的自动标题会在几秒后把它覆盖(实测)。
537
+ // CLI 库不可写(旧版无 title_source 列 / 无该会话行)不阻断:退回
538
+ // 单写任务索引的旧行为;任务索引随后失败也不回滚 CLI——同步器会补
539
+ for (let attempt = 0; ; attempt++) {
540
+ try {
541
+ const r = await setCustomSessionTitle(dataRoot, sessId, title);
542
+ store = r.written ? '' : (r.skipped || 'skipped');
543
+ break;
544
+ } catch (err) {
545
+ if (err.code === 'index-busy' && attempt < 2) {
546
+ await new Promise((r) => setTimeout(r, 1200));
547
+ continue;
548
+ }
549
+ store = 'error: ' + (err?.message || err);
550
+ titleGenLog(dataRoot, `cli-store-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
551
+ break;
552
+ }
553
+ }
494
554
  // 写库撞上宿主写入时稍候重试(起名不急这几秒)
495
555
  for (let attempt = 0; ; attempt++) {
496
556
  try {
@@ -502,12 +562,19 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
502
562
  await new Promise((r) => setTimeout(r, 1200));
503
563
  continue;
504
564
  }
565
+ titleGenLog(dataRoot, `index-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
505
566
  json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
506
567
  return;
507
568
  }
508
569
  }
509
570
  }
510
- json(res, 200, { ok: true, title, written });
571
+ // CLI 库写成功了才登记回查记录(reconcileCustomTitles 只认这些记录校正)
572
+ if (sessId && store === '') {
573
+ const c = loadConfig(configFile);
574
+ c.titleGen.customWritten = { ...(c.titleGen.customWritten || {}), [sessId]: { title, at: Date.now() } };
575
+ saveConfig(configFile, c);
576
+ }
577
+ json(res, 200, { ok: true, title, written, store });
511
578
  return;
512
579
  }
513
580
  // —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
@@ -910,7 +977,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
910
977
  json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
911
978
  return;
912
979
  }
913
- json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
980
+ json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), sync: loadConfig(configFile).sync });
914
981
  return;
915
982
  }
916
983
  // —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
@@ -1076,7 +1143,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1076
1143
  if (r.ok) updated += 1;
1077
1144
  } else if (!p.installed && only) {
1078
1145
  // 安装只走单插件入口(name 指定=各面板的安装卡):
1079
- // 全量「检查并更新」/启动自动更新只升级已装插件——卸载必须被尊重,
1146
+ // 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
1080
1147
  // 未装插件一律由用户在各 tab 显式点安装
1081
1148
  const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
1082
1149
  steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
@@ -1143,6 +1210,18 @@ function mergeTitleGenConfig(saved) {
1143
1210
  // 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
1144
1211
  // 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
1145
1212
  const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
1213
+ // customWritten:起名功能写过的 { sessId → { title, at } },自动校正只认这些
1214
+ // 记录(见 reconcileCustomTitles)。只收合法会话 id 与短字符串,超 7 天/500 条的丢弃
1215
+ const written = {};
1216
+ const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
1217
+ let kept = 0;
1218
+ for (const [k, v] of Object.entries((s.customWritten && typeof s.customWritten === 'object' && !Array.isArray(s.customWritten)) ? s.customWritten : {})) {
1219
+ if (kept >= 500 || !/^sess_[A-Za-z0-9._-]+$/.test(k)) continue;
1220
+ if (!v || typeof v !== 'object' || typeof v.title !== 'string' || v.title.length > 200) continue;
1221
+ if (!Number.isFinite(v.at) || v.at < cutoff) continue;
1222
+ written[k] = { title: v.title, at: v.at };
1223
+ kept++;
1224
+ }
1146
1225
  return {
1147
1226
  enabled: s.enabled === true,
1148
1227
  provider: provider === 'cli' ? 'session' : provider,
@@ -1151,6 +1230,7 @@ function mergeTitleGenConfig(saved) {
1151
1230
  baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
1152
1231
  apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
1153
1232
  format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
1233
+ customWritten: written,
1154
1234
  };
1155
1235
  }
1156
1236
 
@@ -1179,13 +1259,16 @@ function mergeSyncConfig(saved) {
1179
1259
  login: typeof saved.login === 'string' ? saved.login.slice(0, 300) : '',
1180
1260
  password: typeof saved.password === 'string' ? saved.password.slice(0, 300) : '',
1181
1261
  include: { zcodepro: inc.zcodepro !== false, model: inc.model !== false },
1262
+ historyLimit: Number.isInteger(saved.historyLimit) && saved.historyLimit >= 0 && saved.historyLimit <= 100
1263
+ ? saved.historyLimit : d.historyLimit,
1182
1264
  lastPushAt: Number.isFinite(saved.lastPushAt) ? saved.lastPushAt : null,
1183
1265
  lastPullAt: Number.isFinite(saved.lastPullAt) ? saved.lastPullAt : null,
1184
1266
  };
1185
1267
  }
1186
1268
 
1187
1269
  // POST /sync 表单校验:url/login/password/dir 全量替换(dir 缺省时不动已有值)。
1188
- // URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级
1270
+ // URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级;
1271
+ // historyLimit 可选(缺省时不动已有值),0–100 的整数
1189
1272
  function validateSyncForm(raw) {
1190
1273
  const url = typeof raw.url === 'string' ? raw.url.trim() : '';
1191
1274
  const login = typeof raw.login === 'string' ? raw.login.trim() : '';
@@ -1206,6 +1289,12 @@ function validateSyncForm(raw) {
1206
1289
  }
1207
1290
  out.dir = dir;
1208
1291
  }
1292
+ if (raw.historyLimit !== undefined && raw.historyLimit !== null) {
1293
+ if (!Number.isInteger(raw.historyLimit) || raw.historyLimit < 0 || raw.historyLimit > 100) {
1294
+ return { error: '历史版本保留个数需为 0–100 的整数' };
1295
+ }
1296
+ out.historyLimit = raw.historyLimit;
1297
+ }
1209
1298
  return { sync: out };
1210
1299
  }
1211
1300
 
@@ -1299,8 +1388,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
1299
1388
  // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1300
1389
  // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1301
1390
  // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1302
- // 恢复(旧版 zcode-pro 仍读固定名)
1391
+ // 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
1392
+ // 的各组文件算一个),上传成功后自动删除更早的
1303
1393
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1394
+ const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
1304
1395
  const docs = {};
1305
1396
  if (sync.include.zcodepro) {
1306
1397
  // sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
@@ -1345,10 +1436,13 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1345
1436
  if (put.httpCode < 200 || put.httpCode >= 300) {
1346
1437
  return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
1347
1438
  }
1348
- const putVersion = await putUrl(syncVersionName(kind, stamp));
1349
- if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1350
- if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1351
- return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1439
+ // 历史版本只在保留个数 > 0 时写(0 = 只留固定名最新版,由上传后清理收尾)
1440
+ if (keep > 0) {
1441
+ const putVersion = await putUrl(syncVersionName(kind, stamp));
1442
+ if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1443
+ if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1444
+ return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1445
+ }
1352
1446
  }
1353
1447
  pushed.push(kind);
1354
1448
  }
@@ -1357,7 +1451,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1357
1451
  }
1358
1452
  sync.lastPushAt = Date.now();
1359
1453
  saveConfig(configFile, config);
1360
- return { applied: pushed };
1454
+ // 上传成功后清理超出保留个数的旧历史版本(best-effort,失败不影响上传结果)
1455
+ const pruned = await pruneSyncHistory(sync, proxyUrl, keep);
1456
+ return { applied: pushed, pruned };
1361
1457
  }
1362
1458
 
1363
1459
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
@@ -1500,6 +1596,39 @@ async function listSyncVersions(sync, proxyUrl) {
1500
1596
  return { files };
1501
1597
  }
1502
1598
 
1599
+ // 从云端文件名列表选出待删除的历史版本(导出以便测试脚本直接驱动):带时间戳的
1600
+ // 文件按时间戳段分组——同一次上传的各组文件共用一个时间戳,算“一个”;时间戳
1601
+ // 段定宽零填充,字典序即时间序。保留最近 keep 个,返回更早的各组文件名;
1602
+ // 固定名(zcodepro.json / model.json)和不相干的文件名不会出现在结果里
1603
+ export function syncHistoryStaleNames(names, keep) {
1604
+ const groups = new Map();
1605
+ for (const name of names) {
1606
+ const ts = (String(name).match(/^(?:zcodepro|model)(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.json$/) || [])[1];
1607
+ if (!ts) continue;
1608
+ if (!groups.has(ts)) groups.set(ts, []);
1609
+ groups.get(ts).push(name);
1610
+ }
1611
+ // 时间戳段按新 → 旧排,slice(keep) 剩下的即超出保留个数的更早各组
1612
+ return [...groups.keys()].sort((a, b) => b.localeCompare(a)).slice(Math.max(0, keep)).flatMap((ts) => groups.get(ts));
1613
+ }
1614
+
1615
+ // 上传后清理云端目录里超出保留个数的历史版本:列出文件,删掉 syncHistoryStaleNames
1616
+ // 归出的旧文件。best-effort——列目录或单个删除失败只跳过(下次上传再清),不抛错;
1617
+ // 返回本次删掉的文件数
1618
+ async function pruneSyncHistory(sync, proxyUrl, keep) {
1619
+ const list = await listSyncVersions(sync, proxyUrl);
1620
+ if (list.error) return 0;
1621
+ const stale = syncHistoryStaleNames(list.files.map((f) => f.name), keep);
1622
+ let removed = 0;
1623
+ for (const name of stale) {
1624
+ const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000 });
1625
+ if (!r.ok) continue; // 网络失败:跳过该文件
1626
+ if (r.httpCode !== 404 && (r.httpCode < 200 || r.httpCode >= 300)) continue; // 404 = 已不在,视为已清
1627
+ removed++;
1628
+ }
1629
+ return removed;
1630
+ }
1631
+
1503
1632
  // 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
1504
1633
  function sanitizeAliases(raw) {
1505
1634
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -1607,7 +1736,7 @@ export async function scanIdleBusy(paths, dataRoot) {
1607
1736
  return SCAN_OFF;
1608
1737
  }
1609
1738
 
1610
- // Linux:/proc 直读(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
1739
+ // Linux:直接读取 /proc(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
1611
1740
  function scanIdleBusyLinux(paths) {
1612
1741
  let pids;
1613
1742
  try { pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d)); }
@@ -1794,7 +1923,7 @@ function sanitizeFontStack(v) {
1794
1923
  return s;
1795
1924
  }
1796
1925
 
1797
- // 视觉代理「跟随供应商」下拉取数:与智能起名共用同一套供应商列表和过滤规则
1926
+ // 视觉代理“跟随供应商”下拉取数:与智能起名共用同一套供应商列表和过滤规则
1798
1927
  //(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
1799
1928
  // 导出以便测试脚本直接驱动。
1800
1929
  export function listVisionProviders() {
@@ -1853,7 +1982,7 @@ const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起
1853
1982
  //(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
1854
1983
  // coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
1855
1984
  // providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
1856
- // individual → 个人、team → 团队),读不到时退回「个人/团队」
1985
+ // individual → 个人、team → 团队),读不到时退回“个人/团队”
1857
1986
  function codingPlanKindByFamily() {
1858
1987
  const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
1859
1988
  const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
@@ -1962,7 +2091,7 @@ async function callAnthropicText(baseUrl, apiKey, model, content) {
1962
2091
  body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1963
2092
  });
1964
2093
  const body = await resp.text();
1965
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
2094
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
1966
2095
  const doc = JSON.parse(body);
1967
2096
  const text = Array.isArray(doc.content)
1968
2097
  ? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
@@ -1988,7 +2117,7 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
1988
2117
  body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1989
2118
  });
1990
2119
  const body = await resp.text();
1991
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
2120
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
1992
2121
  const doc = JSON.parse(body);
1993
2122
  const text = doc.choices?.[0]?.message?.content || '';
1994
2123
  if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
@@ -2008,7 +2137,12 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
2008
2137
  export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
2009
2138
  const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
2010
2139
  const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
2011
- const userContent = `${prompt}\n\n会话的用户消息:\n${text}`;
2140
+ // 注入当前日期(上海时区):起名模型是无状态的,自定义提示词里要求
2141
+ // “MMDD 取对话创建日期”时它无从得知,只能留字面量 MMDD
2142
+ const now = new Date(Date.now() + 8 * 3600 * 1000);
2143
+ const pad = (n) => String(n).padStart(2, '0');
2144
+ const dateLine = `当前日期(Asia/Shanghai):${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}(MMDD 即 ${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())})`;
2145
+ const userContent = `${prompt}\n\n${dateLine}\n\n会话的用户消息:\n${text}`;
2012
2146
  const providerId = (cfg.provider || '').trim();
2013
2147
  const model = (cfg.model || '').trim();
2014
2148
  const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
@@ -2020,16 +2154,165 @@ export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = ''
2020
2154
  return title;
2021
2155
  }
2022
2156
 
2157
+ // 供应商 id 的匹配归一化(导出以便测试):会话索引里登录账号供应商带 account:
2158
+ // 前缀(如 account:bigmodel-individual-coding-plan),供应商列表里同名条目带
2159
+ // builtin: 前缀(builtin:bigmodel-coding-plan),个人/团队是同一端点——剥前缀、
2160
+ // 抹掉 individual/team 差异后按同一家匹配
2161
+ export function normalizeProviderIdForMatch(id) {
2162
+ return String(id || '')
2163
+ .replace(/^(account|builtin):/, '')
2164
+ .replace(/-(individual|team)-/, '-');
2165
+ }
2166
+
2167
+ // —— 登录账号供应商(Coding Plan)直连凭据的换取 ——
2168
+ // Coding Plan 的直连密钥不是静态保存的,官方桌面/CLI 用 OAuth access_token
2169
+ // 实时换“项目 token”。这里复刻同款流程(协议读自官方 project-access-token-
2170
+ // client.ts),让只登录、从未配置过供应商的机器也能直连起名:
2171
+ // ① 解密 v2/credentials.json 里的 oauth:<family>:access_token——加密格式
2172
+ // enc:v1:<iv>.<authTag>.<cipher>(AES-256-GCM),密钥推导与官方
2173
+ // credential-cipher.ts 一致(ZCODE_CREDENTIAL_SECRET 环境变量优先,否则
2174
+ // sha256("zcode-credential-fallback:平台:主目录:用户名")),同机同用户可解;
2175
+ // ② GET getCustomerInfo 定位个人(非团队)项目——优先名称含“默认机构/
2176
+ // 默认项目”的条目,否则取第一个;
2177
+ // ③ 在项目的 api_keys 里找官方建的 zcode-api-key(没有则创建);
2178
+ // ④ POST …/access_tokens 换项目 token(临期前进程内缓存复用)。
2179
+ // 管理接口的鉴权:bigmodel 用原始 token、zai 带 Bearer 前缀;签发接口两家
2180
+ // 都用 Bearer——与官方实现一致
2181
+ const ACCOUNT_FAMILY_BASE = {
2182
+ bigmodel: { manageOrigin: 'https://bigmodel.cn', apiBase: 'https://open.bigmodel.cn/api/anthropic' },
2183
+ zai: { manageOrigin: 'https://api.z.ai', apiBase: 'https://api.z.ai/api/anthropic' },
2184
+ };
2185
+ const accountTokenCache = new Map(); // family → { token, expiresAt }
2186
+
2187
+ // 解密 credentials.json 的 enc:v1: 值(非加密值原样返回;失败返回空串)
2188
+ export function decryptCredentialValue(value) {
2189
+ const s = String(value || '');
2190
+ if (!s.startsWith('enc:v1:')) return s;
2191
+ const [ivRaw, tagRaw, ctRaw] = s.slice('enc:v1:'.length).split('.');
2192
+ if (!ivRaw || !tagRaw || !ctRaw) return '';
2193
+ try {
2194
+ const secret = (process.env.ZCODE_CREDENTIAL_SECRET || '').trim()
2195
+ || `zcode-credential-fallback:${process.platform}:${homedir()}:${userInfo().username}`;
2196
+ const key = createHash('sha256').update(secret).digest();
2197
+ const d = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
2198
+ d.setAuthTag(Buffer.from(tagRaw, 'base64url'));
2199
+ return Buffer.concat([d.update(Buffer.from(ctRaw, 'base64url')), d.final()]).toString('utf8');
2200
+ } catch { return ''; }
2201
+ }
2202
+
2203
+ // 换取登录账号的项目 token;成功返回 { token, apiBase, expiresAt }
2204
+ export async function exchangeAccountProjectToken(family, dataRoot) {
2205
+ const conf = ACCOUNT_FAMILY_BASE[family];
2206
+ if (!conf) throw new Error(`不支持的供应商族 ${family}`);
2207
+ const cached = accountTokenCache.get(family);
2208
+ if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
2209
+ return { token: cached.token, apiBase: conf.apiBase, expiresAt: cached.expiresAt };
2210
+ }
2211
+ const creds = readJsonFile(join(dataRoot, 'v2', 'credentials.json'));
2212
+ const loginToken = (creds && typeof creds === 'object')
2213
+ ? decryptCredentialValue(creds[`oauth:${family}:access_token`]) : '';
2214
+ if (!loginToken) throw new Error(`credentials.json 里没有可解的 ${family} 登录令牌(未登录或已过期)`);
2215
+
2216
+ // BIGMODEL_API_BASE_URL:官方同款测试环境覆盖,顺带让协议可注入测试
2217
+ const origin = (family === 'bigmodel' && (process.env.BIGMODEL_API_BASE_URL || '').trim())
2218
+ || conf.manageOrigin;
2219
+ // 管理接口沿用旧鉴权格式(bigmodel 原始 token / zai Bearer);签发接口用 Bearer
2220
+ const manageHeaders = {
2221
+ 'content-type': 'application/json',
2222
+ authorization: family === 'zai' ? `Bearer ${loginToken}` : loginToken,
2223
+ };
2224
+ const api = async (path, init = {}) => {
2225
+ const ctrl = new AbortController();
2226
+ const timer = setTimeout(() => ctrl.abort(), 20000);
2227
+ try {
2228
+ const resp = await fetch(origin + path, { ...init, signal: ctrl.signal, headers: manageHeaders });
2229
+ const doc = await resp.json().catch(() => null);
2230
+ const data = (doc && [200, 0, '200', '0'].includes(doc.code)) ? doc.data : null;
2231
+ if (data === null) throw new Error(`接口 ${path} 返回异常(${resp.status})`);
2232
+ return data;
2233
+ } finally { clearTimeout(timer); }
2234
+ };
2235
+
2236
+ // ① 客户信息 → ② 定位个人项目(projectType "2" 是团队项目,个人密钥不能挂在团队下)
2237
+ const cust = await api('/api/biz/customer/getCustomerInfo');
2238
+ const orgs = (Array.isArray(cust?.organizations) ? cust.organizations : [])
2239
+ .map((o) => ({ id: String(o?.organizationId || '').trim(), name: String(o?.organizationName || ''),
2240
+ projects: (Array.isArray(o?.projects) ? o.projects : [])
2241
+ .filter((p) => String(p?.projectType ?? '').trim() !== '2' && String(p?.projectId || '').trim()) }));
2242
+ const org = orgs.find((o) => o.id && o.projects.length && o.name.includes('默认机构')) || orgs.find((o) => o.id && o.projects.length);
2243
+ const project = org && (org.projects.find((p) => String(p?.projectName || '').includes('默认项目')) || org.projects[0]);
2244
+ if (!org || !project) throw new Error('账号下没有可用的个人项目(未订阅 Coding Plan?)');
2245
+
2246
+ // ③ 找官方建的 zcode-api-key,没有就建一个
2247
+ const keysUrl = `/api/biz/v1/organization/${encodeURIComponent(org.id)}/projects/${encodeURIComponent(project.projectId)}/api_keys`;
2248
+ const keys = await api(keysUrl);
2249
+ const keyEntry = (Array.isArray(keys) ? keys : []).find((k) => k && typeof k === 'object' && k.name === 'zcode-api-key')
2250
+ || await api(keysUrl, { method: 'POST', body: JSON.stringify({ name: 'zcode-api-key', usageScene: 1 }) });
2251
+ const apiKeyId = keyEntry && String(keyEntry.apiKey || '').trim();
2252
+ if (!apiKeyId) throw new Error('未能定位 Coding Plan 的 API Key');
2253
+
2254
+ // ④ 签发项目 token(此接口用 Bearer 鉴权;超时与 api() 同款)
2255
+ const issueCtrl = new AbortController();
2256
+ const issueTimer = setTimeout(() => issueCtrl.abort(), 20000);
2257
+ let issueResp;
2258
+ try {
2259
+ issueResp = await fetch(`${origin}${keysUrl}/${encodeURIComponent(apiKeyId)}/access_tokens`, {
2260
+ method: 'POST',
2261
+ signal: issueCtrl.signal,
2262
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${loginToken}` },
2263
+ body: JSON.stringify({ clientType: 'zcode', clientVersion: VERSION }),
2264
+ });
2265
+ } finally { clearTimeout(issueTimer); }
2266
+ const issue = await issueResp.json().catch(() => null);
2267
+ const issueData = (issue && [200, 0, '200', '0'].includes(issue.code)) ? issue.data : null;
2268
+ if (!issueData) throw new Error(`签发项目 token 失败(${issueResp.status})`);
2269
+ // enable 语义与官方一致:旧版接口不回该字段(实测回的是同义的 enabled),
2270
+ // 缺失视为开启,仅显式 false 才算未开启
2271
+ if (issueData.enable === false || !issueData.accessToken) throw new Error('项目 token 未开启(enable=false)');
2272
+ accountTokenCache.set(family, { token: issueData.accessToken, expiresAt: Number(issueData.expiresAt) || 0 });
2273
+ return { token: issueData.accessToken, apiBase: conf.apiBase, expiresAt: Number(issueData.expiresAt) || 0 };
2274
+ }
2275
+
2023
2276
  // 直连前的连接解析(见 generateTitleWith 的取值说明);解析不出抛明确错误
2024
2277
  async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId) {
2025
2278
  if (providerId === 'session') {
2026
- if (!sessId || !dataRoot) {
2027
- throw new Error('跟随当前会话需要会话信息(测试按钮请选择具体供应商或“不跟随”)');
2279
+ if (!dataRoot) throw new Error('跟随当前会话需要会话信息');
2280
+ // sessId 为空(设置面板测试按钮,没有会话上下文)时取任务索引里最近更新
2281
+ // 的会话——与视觉代理测试按钮同款语义
2282
+ const task = await getTaskProviderModel(dataRoot, sessId || '');
2283
+ if (!task || !task.providerId) {
2284
+ throw new Error(sessId ? '索引中查不到该会话的供应商信息' : '任务索引里没有会话记录(“跟随当前会话”取上次会话的供应商/模型):先在任意会话里发过消息,或改选具体供应商再测试');
2285
+ }
2286
+ const list = listTitleGenProviders();
2287
+ const entry = list.find((p) => p.id === task.providerId)
2288
+ || list.find((p) => normalizeProviderIdForMatch(p.id) === normalizeProviderIdForMatch(task.providerId));
2289
+ if (!entry) {
2290
+ // 登录账号供应商:本机没有物化密钥表(从未保存过供应商配置的机器不生成
2291
+ // v2/config.json)时按官方同款流程换取项目 token 直连(见下方实现)
2292
+ if (task.providerId.startsWith('account:')) {
2293
+ if (/start-plan/.test(task.providerId)) {
2294
+ throw new Error('会话用的是 Start Plan 登录供应商,暂不支持自动直连:请在此改选具体供应商或不跟随(自填地址)');
2295
+ }
2296
+ const family = task.providerId.includes(':zai') ? 'zai' : 'bigmodel';
2297
+ let accountConn;
2298
+ try {
2299
+ accountConn = await exchangeAccountProjectToken(family, dataRoot);
2300
+ } catch (err) {
2301
+ throw new Error(`登录账号供应商取直连密钥失败(${err?.message || err}):请在 ZCode 里重新登录,或在此改选具体供应商 / 不跟随`);
2302
+ }
2303
+ const useAccountModel = model || task.modelId;
2304
+ if (!useAccountModel) throw new Error('未能确定会话当前模型,请手动填写模型');
2305
+ return {
2306
+ format: 'anthropic',
2307
+ baseUrl: accountConn.apiBase,
2308
+ apiKey: accountConn.token,
2309
+ // 官方目录的模型名是大写 GLM- 前缀;会话索引里可能是小写(不同供应商
2310
+ // 记法不一),登录端点按目录写法规整
2311
+ model: /^glm-\d/.test(useAccountModel) ? useAccountModel.replace(/^glm/, 'GLM') : useAccountModel,
2312
+ };
2313
+ }
2314
+ throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
2028
2315
  }
2029
- const task = await getTaskProviderModel(dataRoot, sessId);
2030
- if (!task || !task.providerId) throw new Error('索引中查不到该会话的供应商信息');
2031
- const entry = listTitleGenProviders().find((p) => p.id === task.providerId);
2032
- if (!entry) throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
2033
2316
  const useModel = model || task.modelId;
2034
2317
  if (!useModel) throw new Error('未能确定会话当前模型,请手动填写模型');
2035
2318
  return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model: useModel };
@@ -2056,12 +2339,19 @@ async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessI
2056
2339
  }
2057
2340
 
2058
2341
  // 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
2059
- // 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。导出以便测试
2342
+ // 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。自定义提示词常让
2343
+ // 模型“先说明再给标题”(如“无法直接修改会话标题……建议手动设置:”后另起一行
2344
+ // 【MMDD|类型|主题】),此时首个非空行是说明而非标题——优先取独立成行的
2345
+ // 【】/[] 包裹标题行(允许行首 markdown 标题符)。导出以便测试
2060
2346
  export function cleanGeneratedTitle(text) {
2061
2347
  let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
2062
- // 整段包在代码围栏里时先去掉围栏标记(先取行会把「```json」当成标题内容)
2348
+ // 整段包在代码围栏里时先去掉围栏标记(先取行会把“```json”当成标题内容)
2063
2349
  t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
2064
- let line = t.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
2350
+ const lines = t.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
2351
+ // 标题行判定前剥掉 markdown 外壳(## 标题符、**加粗**、*斜体*、_下划线_、~~删除线~~)
2352
+ const unwrap = (s) => s.replace(/^#{1,4}\s*/, '').replace(/^[*_~]+/, '').replace(/[*_~]+$/, '').trim();
2353
+ const bracketed = lines.find((s) => /^(?:【[^】]{1,60}】|\[[^\]\n]{1,60}\])$/.test(unwrap(s)));
2354
+ let line = bracketed ? unwrap(bracketed) : (lines[0] || '');
2065
2355
  line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
2066
2356
  try {
2067
2357
  const doc = JSON.parse(line);
@@ -2096,7 +2386,7 @@ export function validateVisionConfig(raw) {
2096
2386
  apiTimeoutMs: clampInt(raw.apiTimeoutMs, 1000, 600000, 120000),
2097
2387
  compressThresholdKB: clampInt(raw.compressThresholdKB, 0, 102400, 1024),
2098
2388
  // 连续失败跳过(0 = 不跳过);跳过时长(分钟)
2099
- skipAfterFailures: clampInt(raw.skipAfterFailures, 0, 100, 4),
2389
+ skipAfterFailures: clampInt(raw.skipAfterFailures, 0, 100, 2),
2100
2390
  skipMinutes: clampInt(raw.skipMinutes, 1, 10080, 30),
2101
2391
  // 主模型能看图时是否也拦截识别(false = 跳过;flash 主模型始终不触发,见插件)
2102
2392
  forceIntercept: raw.forceIntercept !== false,
@@ -2411,7 +2701,7 @@ async function ensureUserPathEntry(dir) {
2411
2701
  }
2412
2702
 
2413
2703
  // 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
2414
- // 不能按目录扫描:卸载只删「当前指向」的版本目录,缓存里的历史版本目录会残留
2704
+ // 不能按目录扫描:卸载只删“当前指向”的版本目录,缓存里的历史版本目录会残留
2415
2705
  // (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
2416
2706
  // 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
2417
2707
  // 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
@@ -2430,7 +2720,7 @@ function findVisionHook() {
2430
2720
  return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
2431
2721
  }
2432
2722
 
2433
- // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示「插件 x.y.z」用)
2723
+ // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示“插件 x.y.z”用)
2434
2724
  function installedPluginVersion(pluginName) {
2435
2725
  const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2436
2726
  const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
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 });