zen-gitsync 2.17.27 → 2.17.29

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.
Files changed (77) hide show
  1. package/README.md +12 -8
  2. package/package.json +5 -1
  3. package/src/config.js +8 -0
  4. package/src/ui/public/assets/AgentEngineSelector-Wl7dKYKL.js +2 -0
  5. package/src/ui/public/assets/{AgentView-BaYMwfC4.css → AgentView-Bebv5xk6.css} +1 -1
  6. package/src/ui/public/assets/AgentView-CebQ3545.js +11 -0
  7. package/src/ui/public/assets/{AppVersionBadge-BVZKGETC.js → AppVersionBadge-jm8ejuZ-.js} +2 -2
  8. package/src/ui/public/assets/BranchSelector-CcNILIbg.js +1 -0
  9. package/src/ui/public/assets/CommitForm-1ocxo8Cb.js +6 -0
  10. package/src/ui/public/assets/CommonDialog-DpFTO5cg.js +1 -0
  11. package/src/ui/public/assets/EditorView-CtRHV1Sv.js +37 -0
  12. package/src/ui/public/assets/FileDiffViewer-DXJO-G55.js +15 -0
  13. package/src/ui/public/assets/{FlowExecutionViewer-Dp3ZiatT.js → FlowExecutionViewer-d4O5Y9Ca.js} +1 -1
  14. package/src/ui/public/assets/FlowOrchestrationWorkspace-CKLRMeaL.js +21 -0
  15. package/src/ui/public/assets/{IconButton-BburHRSR.js → IconButton-Dl1cdP_x.js} +1 -1
  16. package/src/ui/public/assets/LogList-CdDRC_CO.js +7 -0
  17. package/src/ui/public/assets/MindmapView-U84MWPHc.js +2 -0
  18. package/src/ui/public/assets/MonitorView-spOV0cP7.js +1 -0
  19. package/src/ui/public/assets/ProjectStartupButton-BbAbfFn2.js +1 -0
  20. package/src/ui/public/assets/{RecentDirectoriesChat-6OG0eOqz.js → RecentDirectoriesChat-CxNjjgVG.js} +2 -2
  21. package/src/ui/public/assets/RemoteManagerDialog-BVm2-Hn4.js +4 -0
  22. package/src/ui/public/assets/RemoteRepoCard-UHK_hrDS.js +7 -0
  23. package/src/ui/public/assets/SourceMapView-PHMr0wt3.js +3 -0
  24. package/src/ui/public/assets/{SvgIcon-22vrNtgL.js → SvgIcon-Dk7WnZLB.js} +1 -1
  25. package/src/ui/public/assets/TemplateManager-B3ZNTXnP.js +1 -0
  26. package/src/ui/public/assets/UserInputNode-DeAD7H0z.js +1 -0
  27. package/src/ui/public/assets/WorkbenchView-DsZ2ONpW.js +143 -0
  28. package/src/ui/public/assets/WorkbenchView-kZ8DoH2l.css +1 -0
  29. package/src/ui/public/assets/{_plugin-vue_export-helper-DRfZJqP7.js → _plugin-vue_export-helper-BKeZSroN.js} +5 -5
  30. package/src/ui/public/assets/{agentConversations-17Pw5IQJ.js → agentConversations-ChTc-LkQ.js} +5 -5
  31. package/src/ui/public/assets/configStore-fJaic3kE.js +1 -0
  32. package/src/ui/public/assets/{element-plus-AaVXaSqv.js → element-plus-D0R8BMj4.js} +1 -1
  33. package/src/ui/public/assets/{flow-mindmap-C22NSZc1.js → flow-mindmap-Doi70WWn.js} +52 -52
  34. package/src/ui/public/assets/gitStore-D9oF5nof.js +6 -0
  35. package/src/ui/public/assets/index-CKxygqdx.js +148 -0
  36. package/src/ui/public/assets/index-DVpFh9xE.css +1 -0
  37. package/src/ui/public/assets/{office-docx-CJ6JSCs6.js → office-docx-O0YXk5gA.js} +6 -6
  38. package/src/ui/public/assets/{office-excel-BOjWK-1x.js → office-excel-CXD9VwgB.js} +1 -1
  39. package/src/ui/public/assets/{office-pptx-CF43ZwXE.js → office-pptx-CiF2OlTq.js} +1 -1
  40. package/src/ui/public/assets/{previewSandbox-BOIpC6hW.js → previewSandbox-21uStCdd.js} +2 -2
  41. package/src/ui/public/assets/toolsStore-C89takm3.js +1 -0
  42. package/src/ui/public/assets/{useThemeObserver-CiUpR4zj.js → useThemeObserver-BvSA7RDh.js} +1 -1
  43. package/src/ui/public/assets/{vendor-viKaN257.js → vendor-CCrjQw5C.js} +12 -12
  44. package/src/ui/public/assets/vue-flow-5Aq2bwoG.js +39 -0
  45. package/src/ui/public/index.html +17 -17
  46. package/src/ui/server/routes/fs.js +12 -2
  47. package/src/ui/server/routes/workbench/agentMarketplace.js +130 -23
  48. package/src/ui/server/routes/workbench/agentMarketplace.test.js +68 -1
  49. package/src/ui/server/routes/workbench/envContext.js +69 -29
  50. package/src/ui/server/routes/workbench/envContext.test.js +68 -0
  51. package/src/ui/server/routes/workbench/index.js +10 -3
  52. package/src/ui/server/routes/workbench/taskRunner.js +11 -4
  53. package/src/ui/public/assets/AgentEngineSelector-BUZYAFe-.js +0 -2
  54. package/src/ui/public/assets/AgentView-BFSmHCq7.js +0 -10
  55. package/src/ui/public/assets/BranchSelector-DHWPodUA.js +0 -1
  56. package/src/ui/public/assets/CommitForm-OhIAOaq7.js +0 -6
  57. package/src/ui/public/assets/CommonDialog-BXR1wKBq.js +0 -1
  58. package/src/ui/public/assets/EditorView-8SLDYcaH.js +0 -37
  59. package/src/ui/public/assets/FileDiffViewer-CfMdcN2j.js +0 -15
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-D97DHgyy.js +0 -21
  61. package/src/ui/public/assets/LogList-gFCt4Zlu.js +0 -7
  62. package/src/ui/public/assets/MindmapView-N67x62Qk.js +0 -2
  63. package/src/ui/public/assets/MonitorView-BQr56ZSC.js +0 -1
  64. package/src/ui/public/assets/ProjectStartupButton-DuWf9Z0m.js +0 -1
  65. package/src/ui/public/assets/RemoteManagerDialog-DrR79IRj.js +0 -4
  66. package/src/ui/public/assets/RemoteRepoCard-BU6qOfoG.js +0 -7
  67. package/src/ui/public/assets/SourceMapView-Ba73NVs8.js +0 -3
  68. package/src/ui/public/assets/TemplateManager-C9CzSc6Y.js +0 -1
  69. package/src/ui/public/assets/UserInputNode-4m8zpMv9.js +0 -1
  70. package/src/ui/public/assets/WorkbenchView-B4F_TeL5.css +0 -1
  71. package/src/ui/public/assets/WorkbenchView-CcqEZ_GQ.js +0 -142
  72. package/src/ui/public/assets/configStore-Gzz89_z_.js +0 -1
  73. package/src/ui/public/assets/gitStore--fzrTf_y.js +0 -6
  74. package/src/ui/public/assets/index-C7Ad5BGO.css +0 -1
  75. package/src/ui/public/assets/index-EJrJOx7s.js +0 -142
  76. package/src/ui/public/assets/toolsStore-B3bFcRXP.js +0 -1
  77. package/src/ui/public/assets/vue-flow-DwYB6Myr.js +0 -39
@@ -791,12 +791,19 @@ export function registerFsRoutes({
791
791
  const resolved = safe.safePath;
792
792
  const stat = await fs.stat(resolved);
793
793
  if (!stat.isFile()) throw new HttpError(400, '目标不是文件');
794
+ // meta=1:只回答"盘上这份还是不是客户端手上那份",不回正文。
795
+ // 文件空间在窗口重新聚焦 / 切标签 / 切回本视图时用它做一次廉价比对 ——
796
+ // 一个 stat 就够,不必每个来回都重读整个文件(大文件尤其明显)。
797
+ if (req.query.meta === '1') {
798
+ return res.json({ success: true, mtimeMs: stat.mtimeMs, size: stat.size });
799
+ }
794
800
  // 超过 2MB 不读取
795
801
  if (stat.size > 2 * 1024 * 1024) {
796
802
  return res.json({ success: false, error: '文件过大(> 2 MB),暂不支持在线编辑' });
797
803
  }
798
804
  const content = await fs.readFile(resolved, 'utf-8');
799
- res.json({ success: true, content });
805
+ // mtimeMs 随正文一起给:客户端拿它当"我手上这份对应盘上哪个版本"的基线
806
+ res.json({ success: true, content, mtimeMs: stat.mtimeMs });
800
807
  } catch (error) {
801
808
  res.status(500).json({ success: false, error: error.message });
802
809
  }
@@ -915,7 +922,10 @@ export function registerFsRoutes({
915
922
  const safe = await safePathInProject(filePath);
916
923
  if (!safe) throw new HttpError(403, '禁止写入工作目录以外的文件');
917
924
  await fs.writeFile(safe.safePath, content, 'utf-8');
918
- res.json({ success: true });
925
+ // 回写后的 mtime 一并返回:客户端据此推进自己那份比对基线。
926
+ // 不给的话,下一次聚焦同步会把"自己刚保存的这一笔"误判成外部改动而重载一遍。
927
+ const after = await fs.stat(safe.safePath);
928
+ res.json({ success: true, mtimeMs: after.mtimeMs });
919
929
  } catch (error) {
920
930
  res.status(500).json({ success: false, error: error.message });
921
931
  }
@@ -40,8 +40,9 @@
40
40
  import path from 'node:path';
41
41
  import os from 'node:os';
42
42
  import { promises as fs } from 'node:fs';
43
- import { execFile } from 'node:child_process';
43
+ import { execFile, spawn } from 'node:child_process';
44
44
  import { promisify } from 'node:util';
45
+ import open from 'open';
45
46
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
46
47
  import { AI_SKILLS_DIR, AI_MCP_FILE } from '../../../../paths.js';
47
48
 
@@ -737,13 +738,77 @@ function npmBinary() {
737
738
  return process.platform === 'win32' ? 'npm.cmd' : 'npm';
738
739
  }
739
740
 
741
+ /**
742
+ * 扫描 root 下两层内所有含 SKILL.md 的目录。
743
+ * 集合型仓库很常见(一个仓库十几二十个 skill),必须把全部候选都收上来再挑,
744
+ * 不能像以前那样"按目录顺序撞见第一个就用"。
745
+ *
746
+ * @returns {Promise<Array<{ dir: string, rel: string }>>} rel 用 / 分隔,仓库根为 ''
747
+ */
748
+ async function collectSkillDirs(root) {
749
+ const found = [];
750
+ async function walk(dir, rel, depth) {
751
+ if (await exists(path.join(dir, 'SKILL.md'))) found.push({ dir, rel });
752
+ if (depth >= 2) return;
753
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
754
+ for (const entry of entries) {
755
+ // 跳过 .git 与 .claude-plugin 这类点目录:它们不是 skill 本体
756
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
757
+ await walk(path.join(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name, depth + 1);
758
+ }
759
+ }
760
+ await walk(root, '', 0);
761
+ return found;
762
+ }
763
+
764
+ /** 目录名归一化:大小写、空格、下划线都抹平,`Taste Skill` 与 `taste-skill` 应当能对上。 */
765
+ function normalizeSkillName(value) {
766
+ return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
767
+ }
768
+
769
+ /**
770
+ * 从候选目录里挑出"用户点的那个 skill"。
771
+ *
772
+ * 为什么不能直接取第一个:仓库 `Leonxlnx/taste-skill` 的 `skills/` 下有十几个 skill,
773
+ * 目录顺序第一个是 `brandkit`。旧逻辑会把 brandkit 装进去,而卡片的 id 仍然记成
774
+ * `github-Leonxlnx-taste-skill` —— 于是广场卡片显示"已安装",已安装列表里却是
775
+ * `brandkit` 这个名字,用户根本对不上,只能怀疑"是不是没装成功"。
776
+ *
777
+ * @returns {{ dir: string, rel: string } | null} null = 挑不出来(并列歧义),交给调用方报错
778
+ */
779
+ function pickSkillDir(candidates, { subpath = '', skillName = '' } = {}) {
780
+ if (!candidates.length) return null;
781
+ const wantSub = String(subpath || '').replace(/^\.\//, '').replace(/\/+$/, '').toLowerCase();
782
+ const wantName = normalizeSkillName(skillName);
783
+
784
+ const rank = ({ rel }) => {
785
+ const norm = String(rel).toLowerCase();
786
+ const base = normalizeSkillName(norm.split('/').pop());
787
+ if (wantSub && norm === wantSub) return 0; // 条目自己给的就是这个子目录
788
+ if (wantName && base === wantName) return 1; // 目录名就是用户点的名字
789
+ if (!rel) return 2; // 仓库根就是一份 SKILL.md
790
+ // 名字沾边(如 taste-skill-v1 / taste-skill)。必须卡在 - 边界上:
791
+ // 用裸 includes 的话,目录 `a` 会被判成 `whatever` 的变体,短名一律乱匹配。
792
+ if (wantName && base && (
793
+ base.startsWith(`${wantName}-`) || base.endsWith(`-${wantName}`)
794
+ || wantName.startsWith(`${base}-`) || wantName.endsWith(`-${base}`)
795
+ )) return 3;
796
+ return 4;
797
+ };
798
+
799
+ const ranked = candidates.map(item => ({ ...item, rank: rank(item) })).sort((a, b) => a.rank - b.rank);
800
+ // 首选并列 → 宁可报错让用户自己说清是哪一个,也不要悄悄装错
801
+ if (ranked.length > 1 && ranked[1].rank === ranked[0].rank) return null;
802
+ return ranked[0];
803
+ }
804
+
740
805
  /**
741
806
  * 只拉取仓库里的某个子目录。
742
807
  * 用 --filter=blob:none --sparse 是为了对付 monorepo ——
743
808
  * 直接 --depth 1 全量克隆一个几万文件的仓库只为拿一个 skill 太浪费。
744
809
  * sparse 不被支持(旧版 git / 服务端限制)时回落到普通浅克隆。
745
810
  */
746
- async function cloneSubdirectory({ repository, subpath, dest }) {
811
+ async function cloneSubdirectory({ repository, subpath, dest, skillName = '' }) {
747
812
  const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-gitsync-skill-'));
748
813
  const url = `https://github.com/${repository}.git`;
749
814
  try {
@@ -767,29 +832,17 @@ async function cloneSubdirectory({ repository, subpath, dest }) {
767
832
  await execFileAsync(gitBinary(), ['clone', '--depth', '1', url, temp], { timeout: CLONE_TIMEOUT_MS, windowsHide: true });
768
833
  }
769
834
 
770
- const requested = subpath ? path.join(temp, subpath) : '';
771
- const candidates = [requested, path.join(temp, 'skills'), temp].filter(Boolean);
772
- let source = null;
773
- for (const candidate of candidates) {
774
- if (await exists(path.join(candidate, 'SKILL.md'))) { source = candidate; break; }
835
+ const candidates = await collectSkillDirs(temp);
836
+ const picked = pickSkillDir(candidates, { subpath, skillName });
837
+ if (!picked) {
838
+ if (!candidates.length) throw new HttpError(422, `仓库 ${repository} 里没有找到 SKILL.md`);
839
+ const names = candidates.slice(0, 8).map(item => item.rel || '仓库根').join('、');
840
+ const more = candidates.length > 8 ? ` 等 ${candidates.length} 个` : '';
841
+ throw new HttpError(422, `仓库 ${repository} 里有多个 skill,且没有一个跟「${skillName || repository}」对得上(${names}${more}),请从具体子目录再装`);
775
842
  }
776
- // 再退一步:找仓库里任意一层含 SKILL.md 的目录(只扫两层,避免在 monorepo 里乱窜)
777
- if (!source) {
778
- const top = await fs.readdir(temp, { withFileTypes: true }).catch(() => []);
779
- for (const dir of top.filter(entry => entry.isDirectory() && entry.name !== '.git')) {
780
- if (await exists(path.join(temp, dir.name, 'SKILL.md'))) { source = path.join(temp, dir.name); break; }
781
- const inner = await fs.readdir(path.join(temp, dir.name), { withFileTypes: true }).catch(() => []);
782
- let hit = null;
783
- for (const sub of inner.filter(entry => entry.isDirectory())) {
784
- if (await exists(path.join(temp, dir.name, sub.name, 'SKILL.md'))) { hit = path.join(temp, dir.name, sub.name); break; }
785
- }
786
- if (hit) { source = hit; break; }
787
- }
788
- }
789
- if (!source) throw new HttpError(422, `仓库 ${repository} 里没有找到 SKILL.md`);
790
843
 
791
844
  await fs.mkdir(path.dirname(dest), { recursive: true });
792
- await fs.cp(source, dest, { recursive: true, force: false });
845
+ await fs.cp(picked.dir, dest, { recursive: true, force: false });
793
846
  } finally {
794
847
  await fs.rm(temp, { recursive: true, force: true }).catch(() => {});
795
848
  }
@@ -806,7 +859,7 @@ async function installSkill({ item, target, cwd }) {
806
859
  const repository = String(item.repository || '').trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/, '');
807
860
  if (repository && SAFE_REPOSITORY.test(repository)) {
808
861
  const subpath = item.subpath && isSafeSubpath(item.subpath) ? String(item.subpath) : '';
809
- await cloneSubdirectory({ repository, subpath, dest });
862
+ await cloneSubdirectory({ repository, subpath, dest, skillName: String(item.name || id) });
810
863
  return { id, type: 'skill', name: item.name || id, target, dir: dest, source: repository };
811
864
  }
812
865
 
@@ -893,6 +946,29 @@ async function installMcp({ item, target, cwd }) {
893
946
  return { id, type: 'mcp', name: item.name || id, target, file: paths.file, envKeys: missingEnv, warning: installWarning };
894
947
  }
895
948
 
949
+ /**
950
+ * 在系统文件管理器里定位一个已安装项:
951
+ * 目录 → 直接打开;文件(如 .mcp.json)→ 在其中选中它。
952
+ * Linux 没有"选中文件"的标准接口,退化为打开所在目录。
953
+ * 与 /api/editor/reveal 同一套行为,只是那个接口只认工作目录以内的路径,
954
+ * 这里要能定位 ~/.zen-gitsync/ai 下的全局安装项。
955
+ */
956
+ async function revealInFileManager(resolved) {
957
+ const stat = await fs.stat(resolved);
958
+ if (stat.isDirectory()) {
959
+ await open(resolved, { wait: false });
960
+ return;
961
+ }
962
+ if (process.platform === 'win32') {
963
+ // explorer.exe /select,<path>(逗号分隔,无双引号,argv 数组防二次解释)
964
+ spawn('explorer.exe', ['/select,', resolved], { detached: true, stdio: 'ignore' }).unref();
965
+ } else if (process.platform === 'darwin') {
966
+ spawn('open', ['-R', resolved], { detached: true, stdio: 'ignore' }).unref();
967
+ } else {
968
+ await open(path.dirname(resolved), { wait: false });
969
+ }
970
+ }
971
+
896
972
  async function uninstall({ type, id, target, cwd }) {
897
973
  if (!SAFE_ID.test(id)) throw new HttpError(400, '参数不合法');
898
974
  const paths = targetPaths({ type, target, cwd });
@@ -987,6 +1063,33 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
987
1063
  res.json({ success: true, target, cwd, item: installed });
988
1064
  }));
989
1065
 
1066
+ // 在文件管理器里定位已安装项。路径由服务端用同一份 targetPaths 推导,
1067
+ // 前端只传 id —— 不接收任意路径,天然不给出路径穿越的口子。
1068
+ app.post('/api/agent/marketplace/reveal', asyncRoute(async (req, res) => {
1069
+ const type = normalizeType(req.body?.type);
1070
+ if (!type) throw new HttpError(400, '未知扩展类型');
1071
+ const target = normalizeTarget(req.body?.target);
1072
+ const cwd = resolveProjectPath(req.body?.cwd, getCurrentProjectPath?.());
1073
+ const id = String(req.body?.id || '');
1074
+ if (type === 'skill' && !SAFE_ID.test(id)) throw new HttpError(400, '参数不合法');
1075
+ if (type === 'mcp' && id && !SAFE_ID.test(id)) throw new HttpError(400, '参数不合法');
1076
+
1077
+ const paths = targetPaths({ type, target, cwd });
1078
+ let resolved;
1079
+ if (type === 'skill') {
1080
+ const root = path.resolve(paths.root);
1081
+ resolved = path.resolve(root, id);
1082
+ if (!resolved.startsWith(`${root}${path.sep}`)) throw new HttpError(400, '路径不合法');
1083
+ } else {
1084
+ // MCP 没有独立目录,定位到配置文件本身(在其中选中)
1085
+ resolved = path.resolve(paths.file);
1086
+ }
1087
+ if (!(await exists(resolved))) throw new HttpError(404, '目标不存在');
1088
+
1089
+ await revealInFileManager(resolved);
1090
+ res.json({ success: true, type, target, path: resolved });
1091
+ }));
1092
+
990
1093
  app.delete('/api/agent/marketplace/item/:type/:id', asyncRoute(async (req, res) => {
991
1094
  const type = normalizeType(req.params.type);
992
1095
  if (!type) throw new HttpError(400, '未知扩展类型');
@@ -998,3 +1101,7 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
998
1101
  res.json({ success: true, ...result });
999
1102
  }));
1000
1103
  }
1104
+
1105
+ // 给回归测试用:集合型仓库"装哪一个"的挑选逻辑必须能被单独钉住
1106
+ // (真跑一遍 git clone 要联网,测试里不现实)
1107
+ export { pickSkillDir, collectSkillDirs };
@@ -37,7 +37,7 @@ process.env.HOME = sandboxHome;
37
37
  delete process.env.HOMEDRIVE;
38
38
  delete process.env.HOMEPATH;
39
39
 
40
- const { registerAgentMarketplaceRoutes } = await import('./agentMarketplace.js');
40
+ const { registerAgentMarketplaceRoutes, pickSkillDir, collectSkillDirs } = await import('./agentMarketplace.js');
41
41
  const { AI_SKILLS_DIR, AI_MCP_FILE } = await import('../../../../paths.js');
42
42
 
43
43
  // ── 测试脚手架:把注册的 handler 抓出来直接调 ──────────────────
@@ -336,3 +336,70 @@ test('install: 项目不存在时拒绝,不会把 .claude/skills 建到奇怪的
336
336
  assert.equal(status, 400);
337
337
  assert.match(payload.error, /项目目录不存在/);
338
338
  });
339
+
340
+ // ── 集合型仓库"装哪一个" ────────────────────────────────────────
341
+ // 踩过的坑:Leonxlnx/taste-skill 的 skills/ 下第一个是 brandkit,
342
+ // 旧逻辑"按目录顺序撞见第一个含 SKILL.md 的就装" → 点 taste-skill 装成 brandkit。
343
+ // 卡片 id 仍是 github-Leonxlnx-taste-skill 所以显示"已安装",
344
+ // 已安装列表里却是 brandkit 这个名字,用户根本对不上,只能怀疑没装成功。
345
+
346
+ test('pickSkillDir: 候选里挑与条目同名的那个,不取目录顺序第一个', () => {
347
+ const candidates = [
348
+ { dir: 'T/skills/brandkit', rel: 'skills/brandkit' },
349
+ { dir: 'T/skills/taste-skill', rel: 'skills/taste-skill' },
350
+ { dir: 'T/skills/taste-skill-v1', rel: 'skills/taste-skill-v1' },
351
+ ];
352
+ assert.equal(pickSkillDir(candidates, { skillName: 'taste-skill' }).rel, 'skills/taste-skill');
353
+ });
354
+
355
+ test('pickSkillDir: 条目给了子目录时以子目录为准,名字对得上也不用', () => {
356
+ const candidates = [
357
+ { dir: 'T/skills/taste', rel: 'skills/taste' },
358
+ { dir: 'T/skills/taste-skill', rel: 'skills/taste-skill' },
359
+ ];
360
+ assert.equal(pickSkillDir(candidates, { subpath: 'skills/taste', skillName: 'taste-skill' }).rel, 'skills/taste');
361
+ });
362
+
363
+ test('pickSkillDir: 名字对不上且候选并列时返回 null(报错,而不是猜一个)', () => {
364
+ const candidates = [
365
+ { dir: 'T/alpha', rel: 'alpha' },
366
+ { dir: 'T/beta', rel: 'beta' },
367
+ ];
368
+ assert.equal(pickSkillDir(candidates, { skillName: 'whatever' }), null);
369
+ });
370
+
371
+ test('pickSkillDir: 短目录名不会被裸 includes 误判成变体', () => {
372
+ const only = [{ dir: 'T/a', rel: 'a' }];
373
+ // wantName='whatever' 包含子串 'a',裸 includes 会把它当 rank 3 命中
374
+ assert.equal(pickSkillDir([...only, { dir: 'T/b', rel: 'b' }], { skillName: 'whatever' }), null);
375
+ assert.equal(pickSkillDir(only, { skillName: 'whatever' }).rel, 'a');
376
+ });
377
+
378
+ test('pickSkillDir: 仓库根就一份 SKILL.md 时优先于没名字的深层目录', () => {
379
+ const candidates = [
380
+ { dir: 'T/sub/other', rel: 'sub/other' },
381
+ { dir: 'T', rel: '' },
382
+ ];
383
+ assert.equal(pickSkillDir(candidates, { skillName: 'anything' }).rel, '');
384
+ });
385
+
386
+ test('pickSkillDir: 没有候选时返回 null(调用方报"仓库里没有 SKILL.md")', () => {
387
+ assert.equal(pickSkillDir([], { skillName: 'x' }), null);
388
+ });
389
+
390
+ test('collectSkillDirs: 收两层以内的 SKILL.md,跳过 .git 与点目录', async () => {
391
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mp-collect-'));
392
+ const write = async rel => {
393
+ await fs.mkdir(path.join(root, rel), { recursive: true });
394
+ await fs.writeFile(path.join(root, rel, 'SKILL.md'), `---\nname: ${path.basename(rel)}\n---\n`, 'utf8');
395
+ };
396
+ await write('skills/brandkit');
397
+ await write('skills/deep/nested/too-deep');
398
+ await write('.claude-plugin');
399
+ await fs.mkdir(path.join(root, '.git'), { recursive: true });
400
+ await fs.writeFile(path.join(root, '.git', 'SKILL.md'), 'not a skill', 'utf8');
401
+ await fs.writeFile(path.join(root, 'README.md'), '# x', 'utf8');
402
+
403
+ const rels = (await collectSkillDirs(root)).map(item => item.rel).sort();
404
+ assert.deepEqual(rels, ['skills/brandkit']);
405
+ });
@@ -39,6 +39,25 @@
39
39
  // 不经过前端 $t(),也不会显示在界面上。反过来说,它也不该跟着界面语言变 ——
40
40
  // 中文指令配中文上下文,模型理解得更稳。
41
41
  //
42
+ // ── 为什么续聊轮要精简(`compact`)────────────────────────────────────────
43
+ //
44
+ // 续接一轮对话时,prompt = [环境块][记忆块][用户这一轮说的话]。而续接走的是 CLI 自己的
45
+ // 会话恢复(claude `--resume` / opencode `--session` / codex `exec resume`),上一轮那份
46
+ // **完整**环境块已经在会话历史里 —— 整块再发一遍,信息上是纯重复。
47
+ //
48
+ // 实测(本机 jobs.json,2026-09-30):一条续接轮的 prompt 是
49
+ // 环境块 2118~2200 字符 + 记忆块 424 字符 + 用户原话 44 字符
50
+ // 也就是 98% 是重发的背景。代价有两处:一是每轮多烧约两千 token,而且它在**会话尾部**
51
+ // (不是前缀),命不中前缀缓存;二是 job.prompt 会被前端当成用户气泡渲染
52
+ // (useWorkbenchSimpleConversation.ts)—— 聊天记录里每续一轮就多一份一模一样的项目清单,
53
+ // 打开一条跑过多轮的任务,第一屏全是它(WorkbenchView.vue 里那条"钉到底部"的注释同源)。
54
+ //
55
+ // 但**不能整块砍掉**:块里有真正会变的东西(看板计数、正在执行几个、项目增减),
56
+ // 而且会话被压缩/裁剪之后那块背景可能真的不在了。所以续聊轮发的是这个"精简刷新版":
57
+ // 只留 当前项目 + 合计概览 + 真相源路径,项目清单与用户偏好交给前文
58
+ // (引导语里点名"克隆优先 SSH",免得那条行为约定被彻底忘掉)。
59
+ // 记忆块是同一套思路,见 taskRunner.js 的 `capture: !resumeSessionId`。
60
+ //
42
61
  // 纯函数:只吃入参、不读文件(取数在 workbench/index.js,数据在快照里)。
43
62
  // 这样单测不用碰真实用户数据 —— 与 promptParts.js 同一个理由。
44
63
 
@@ -70,6 +89,8 @@ const columnLabels = TASK_COLUMNS.map(k => COLUMN_LABELS[k] || k);
70
89
  * @param {object} [input.truthFiles] { tasksFile, jobsFile, orchestratorFile, configFile }
71
90
  * 路径口径的唯一来源见 workbench/shared.js 的 TRUTH_FILES
72
91
  * @param {number} [input.maxProjects] 只影响列出条数,不影响合计统计
92
+ * @param {boolean} [input.compact] 续聊轮的精简刷新版:只留 当前项目 + 合计概览 + 真相源,
93
+ * 项目清单与用户偏好交给会话前文(见文件头那一节)
73
94
  * @returns {string} 上下文块。**不返回 null** —— 见下面的注释。
74
95
  */
75
96
  export function buildEnvContextBlock({
@@ -77,6 +98,7 @@ export function buildEnvContextBlock({
77
98
  board = null,
78
99
  truthFiles = {},
79
100
  maxProjects = ENV_CONTEXT_MAX_PROJECTS,
101
+ compact = false,
80
102
  } = {}) {
81
103
  const list = Array.isArray(board?.projects) ? board.projects.filter(p => p && p.key) : [];
82
104
  const statsByKey = (board?.statsByKey && typeof board.statsByKey === 'object') ? board.statsByKey : {};
@@ -90,24 +112,33 @@ export function buildEnvContextBlock({
90
112
  const currentKey = canonicalProjectPath(currentProjectPath);
91
113
 
92
114
  const lines = [];
93
- lines.push('[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容]');
115
+ // 首行前缀 `[运行环境` 是探针的锚点(scripts/verify-workbench-env-context.mjs
116
+ // 断言 prompt.startsWith('[运行环境')),精简版也照旧以它开头。
117
+ lines.push(compact
118
+ ? '[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容 · 续聊轮精简刷新,完整项目清单与用户偏好见本会话前文]'
119
+ : '[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容]');
94
120
  lines.push('');
95
121
  lines.push(`当前任务所在项目(也是你的工作目录): ${currentProjectPath || '(未指定)'}`);
96
122
  lines.push('');
97
123
 
98
124
  if (list.length > 0) {
99
- const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
100
- const shown = list.slice(0, limit);
101
- lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
102
- for (const p of shown) {
103
- const s = statsByKey[p.key] || {};
104
- const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
105
- lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
106
- }
107
- if (list.length > shown.length) {
108
- lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
125
+ // 项目清单整段只在首轮发。它占这块的 90%(19 个项目 ≈ 1300 字符)却几乎不变,
126
+ // 续聊轮再发一遍等于把同一张表贴在对话里(见文件头)。
127
+ if (!compact) {
128
+ const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
129
+ const shown = list.slice(0, limit);
130
+ lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
131
+ for (const p of shown) {
132
+ const s = statsByKey[p.key] || {};
133
+ const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
134
+ lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
135
+ }
136
+ if (list.length > shown.length) {
137
+ lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
138
+ }
139
+ lines.push('');
109
140
  }
110
- lines.push('');
141
+ // 计数概览是这块里**真正会变**的部分,首轮与续聊轮都发
111
142
  lines.push(
112
143
  `看板任务概览:全部项目合计 ${total.total || 0} 条 —— ` +
113
144
  TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / ') +
@@ -122,27 +153,36 @@ export function buildEnvContextBlock({
122
153
  lines.push('');
123
154
  }
124
155
 
125
- // 用户偏好(见文件头)。放在"读文件"之前、"看板概览"之后 ——
126
- // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
127
- // 那是整块上下文里最需要被记住的动作。
128
- lines.push(
129
- '用户偏好:克隆仓库 / 添加远端时**优先使用 SSH**(https 地址先换算成 ' +
130
- 'git@github.com:owner/repo.git,Gitee 同理 git@gitee.com:owner/repo.git —— ' +
131
- '走 https 会弹凭据窗口,把任务停在半路等用户输账号密码)。' +
132
- '只有 SSH 不可用(Permission denied (publickey) / Host key verification failed)' +
133
- '才退回 https,并说明这次走的是 https。',
134
- );
135
- lines.push('');
136
- lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
156
+ if (compact) {
157
+ // 引导语替掉整段用户偏好,但**点名**那条行为约定:偏好本身不重述(前文有),
158
+ // 可"有这么一条"得让模型看得见,否则克隆仓库时它会退回 https。
159
+ lines.push('需要完整项目清单 / 进展细节时(含「克隆优先 SSH」偏好),直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
160
+ } else {
161
+ // 用户偏好(见文件头)。放在"读文件"之前、"看板概览"之后 ——
162
+ // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
163
+ // 那是整块上下文里最需要被记住的动作。
164
+ lines.push(
165
+ '用户偏好:克隆仓库 / 添加远端时**优先使用 SSH**(https 地址先换算成 ' +
166
+ 'git@github.com:owner/repo.git,Gitee 同理 git@gitee.com:owner/repo.git —— ' +
167
+ '走 https 会弹凭据窗口,把任务停在半路等用户输账号密码)。' +
168
+ '只有 SSH 不可用(Permission denied (publickey) / Host key verification failed)' +
169
+ '才退回 https,并说明这次走的是 https。',
170
+ );
171
+ lines.push('');
172
+ lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
173
+ }
137
174
  if (truthFiles.tasksFile) lines.push(`- ${truthFiles.tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
138
175
  if (truthFiles.jobsFile) lines.push(`- ${truthFiles.jobsFile} —— 历次执行记录(状态与输出)`);
139
176
  if (truthFiles.orchestratorFile) lines.push(`- ${truthFiles.orchestratorFile} —— 用户在调度台发过的指令流水`);
140
177
  if (truthFiles.configFile) lines.push(`- ${truthFiles.configFile} —— 应用配置(projects / recentDirectories)`);
141
- lines.push('');
142
- lines.push(
143
- '用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
144
- '不要凭当前目录猜测,也不要回答"我看不到/无法访问"。',
145
- );
178
+ // 压尾那句"先读文件再回答"只在首轮讲:续聊轮上面那句引导语已经说了同一件事
179
+ if (!compact) {
180
+ lines.push('');
181
+ lines.push(
182
+ '用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
183
+ '不要凭当前目录猜测,也不要回答"我看不到/无法访问"。',
184
+ );
185
+ }
146
186
 
147
187
  return lines.join('\n');
148
188
  }
@@ -260,3 +260,71 @@ test('真相源清单来自唯一的 TRUTH_FILES(四个路径一个不落,
260
260
  assert.ok(block.includes(TRUTH_FILES.orchestratorFile));
261
261
  assert.match(block, /调度台发过的指令流水/);
262
262
  });
263
+
264
+ // ── 续聊轮精简版(compact)──────────────────────────────────────────────
265
+ //
266
+ // 续接走的是 CLI 自己的会话恢复(claude --resume / opencode --session / codex exec resume),
267
+ // 上一轮那份**完整**块已在会话历史里 —— 续聊轮再发一遍就是纯重复。实测一条续接轮 prompt:
268
+ // 环境块 2118~2200 字符 + 记忆块 424 字符 + 用户原话 44 字符(本机 jobs.json,2026-09-30)。
269
+ //
270
+ // 这组守四件事:
271
+ // 1. 项目清单真的不再发(它占这块 90%,且几乎不变);
272
+ // 2. 会变的那部分(合计概览、当前项目)还在 —— 精简不等于把刷新能力也砍了;
273
+ // 3. 真相源路径照给:会话被压缩后靠它重新够得着数据;
274
+ // 4. 首行锚点不变(scripts/verify-workbench-env-context.mjs 断言 startsWith('[运行环境'))。
275
+ //
276
+ // 数字口径一栏用"两种模式必须给出同一组计数"来守:精简是**裁剪**,不是另算一份。
277
+
278
+ const richBoard = () => {
279
+ // 项目数刻意贴近真实规模(用户本机 19 个):清单的"贵"是随项目数线性涨的,
280
+ // 只放两三个项目的夹具量不出"精简到底省了多少"(这条正是 compact 的存在理由)。
281
+ const recentDirs = ['D:\\ws\\zen-gitsync', 'D:\\ws\\article-generator'];
282
+ const tasks = [
283
+ { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
284
+ { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
285
+ { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
286
+ ];
287
+ for (let i = 0; i < 17; i += 1) recentDirs.push(`D:\\ws\\filler${i}`);
288
+ return boardOf({ recentDirs, tasks, jobs: [job('j1', 't1', 'done'), job('j2', 't2', 'running')] });
289
+ };
290
+
291
+ test('compact 不再发项目清单,但保留当前项目与合计概览', () => {
292
+ const board = richBoard();
293
+ const full = build({ currentProjectPath: 'D:\\ws\\zen-gitsync', board });
294
+ const compact = build({ currentProjectPath: 'D:\\ws\\zen-gitsync', board, compact: true });
295
+
296
+ // ① 清单行没了 —— 连"还有 N 个未列出"这种残余也不能留(模型会以为清单被截断)
297
+ assert.doesNotMatch(compact, /项目清单(共 \d+ 个/);
298
+ assert.doesNotMatch(compact, /^- .+ \| .+ \| \d+\/\d+\/\d+/m);
299
+ assert.doesNotMatch(compact, /还有 \d+ 个未列出/);
300
+ // 另一个项目(没在当前项目栏里出现过的)整体不该出现在精简块里
301
+ assert.ok(!compact.includes('D:\\ws\\article-generator'), '精简块不该再列其它项目');
302
+
303
+ // ② 会变的部分还在,而且数字与完整版**逐字相同**(同一次取数、两种裁剪)
304
+ const overview = full.match(/看板任务概览:全部项目合计 [^\n]+/)[0];
305
+ assert.ok(compact.includes(overview), `精简块应原样保留合计概览:\n${compact}`);
306
+ assert.match(compact, /全部项目合计 3 条/);
307
+ assert.match(compact, /正在执行 1 个/);
308
+ assert.match(compact, /当前任务所在项目(也是你的工作目录): D:\\ws\\zen-gitsync/);
309
+
310
+ // ③ 真相源路径照给(会话被压缩后靠它重新够得着数据)
311
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(compact.includes(p), `缺少路径 ${p}`);
312
+ // 用户偏好不重述,但要点名"有这么一条" —— 少这一句,克隆仓库时会退回 https
313
+ assert.match(compact, /克隆优先 SSH/);
314
+
315
+ // ④ 首行锚点(探针依赖)与完整版同前缀
316
+ assert.ok(compact.startsWith('[运行环境'));
317
+ assert.ok(full.startsWith('[运行环境'));
318
+
319
+ // ⑤ 真省:精简块不到完整版的一半
320
+ assert.ok(compact.length * 2 < full.length, `精简块应显著更短: compact=${compact.length} full=${full.length}`);
321
+ });
322
+
323
+ test('compact 在取不到 board 时也照给真相源(且不返回空)', () => {
324
+ const compact = build({ currentProjectPath: 'D:\\ws\\a', board: null, compact: true });
325
+ assert.ok(compact, '精简版同样不返回 null');
326
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(compact.includes(p), `缺少路径 ${p}`);
327
+ assert.match(compact, /项目清单:本次未能取到/);
328
+ // 没有清单时更不该出现"清单行"的残影
329
+ assert.doesNotMatch(compact, /^- .+ \| .+ \| \d+\/\d+\/\d+/m);
330
+ });
@@ -692,7 +692,12 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
692
692
  promptOverride: userMessage,
693
693
  attachments: carryAtts
694
694
  };
695
- const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
695
+ // 工作目录口径必须与「执行任务」那条路一致:**任务自己的项目优先**,没有才回落当前项目
696
+ // (resolveTaskRepoPath)。这里以前直接用 getCurrentProjectPath(),于是"在 B 项目里打开
697
+ // A 项目的任务续聊"会把 CLI spawn 到 B(甚至空路径 → process.cwd(),即服务器所在的仓库):
698
+ // 续接的这轮在错误的工作区里干活,而 --resume 又把上下文带过来了,症状很隐蔽。
699
+ // 2026-09-30 由 verify:wb-env-context 第 8 组抓到(续聊轮的 prompt 里出现的不是任务的项目)。
700
+ const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
696
701
  res.json({ success: true, message: '已加入续接队列' });
697
702
  // 执行器必须沿用上一轮 job 的 agent:claude 的 --resume 和 opencode 的 --session
698
703
  // 互不认对方的会话 id,串了执行器续接必然失败。
@@ -1125,13 +1130,13 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1125
1130
  * 而快照生成器在服务端入口创建、注册顺序在它之前就已经建好了 —— 但两者是同一次装配,
1126
1131
  * 用 getter 可以避免"必须记得按某个顺序注册"这种隐性契约。
1127
1132
  */
1128
- setEnvContextProvider(async ({ repoPath } = {}) => {
1133
+ setEnvContextProvider(async ({ repoPath, compact } = {}) => {
1129
1134
  const snapshotter = typeof getAiContextSnapshotter === 'function' ? getAiContextSnapshotter() : null;
1130
1135
 
1131
1136
  if (!snapshotter || typeof snapshotter.refreshSections !== 'function') {
1132
1137
  // 快照生成器没装配上(理论上不会发生)。至少把**不需要取数**的两样递进去:
1133
1138
  // 用户偏好与真相源路径 —— 前者少一次就会让任务卡在 https 凭据窗口上。
1134
- return buildEnvContextBlock({ currentProjectPath: repoPath || '', truthFiles: TRUTH_FILES });
1139
+ return buildEnvContextBlock({ currentProjectPath: repoPath || '', truthFiles: TRUTH_FILES, compact });
1135
1140
  }
1136
1141
 
1137
1142
  try {
@@ -1155,6 +1160,8 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1155
1160
  currentProjectPath: repoPath || '',
1156
1161
  board,
1157
1162
  truthFiles: TRUTH_FILES,
1163
+ // 续聊轮走精简刷新版(调用方 taskRunner 按 resumeSessionId 判定)
1164
+ compact,
1158
1165
  });
1159
1166
  });
1160
1167
 
@@ -69,7 +69,9 @@ let envContextProvider = null;
69
69
 
70
70
  /**
71
71
  * 注册运行环境上下文的提供者。由 routes/workbench/index.js 在注册路由时调用。
72
- * @param {(ctx: {repoPath: string}) => Promise<string|null>|string|null} fn
72
+ * @param {(ctx: {repoPath: string, compact?: boolean}) => Promise<string|null>|string|null} fn
73
+ * `compact: true` = 续聊轮,只要精简刷新版(完整清单上一轮已进过 prompt)。
74
+ * 接不接这个字段由提供者自己决定:不接就等于每轮都发整块 —— 老行为,不会报错。
73
75
  */
74
76
  export function setEnvContextProvider(fn) {
75
77
  envContextProvider = typeof fn === 'function' ? fn : null;
@@ -78,13 +80,17 @@ export function setEnvContextProvider(fn) {
78
80
  /**
79
81
  * 取本次执行的运行环境上下文块。
80
82
  *
83
+ * 续聊轮(`resumeSessionId` 有值)传 `compact: true` 只要精简刷新版:完整环境块上一轮
84
+ * 就在 prompt 里了,CLI 的会话恢复会把它带回来,整块重发是纯重复
85
+ * (实测数据与理由见 envContext.js 文件头)。
86
+ *
81
87
  * 提供者抛错一律降级成"没有上下文":读 tasks.json 失败、配置竞态都是可能发生的,
82
88
  * 但它们都不该让用户的指令执行不了 —— 与「读不到最近目录不该让整个看板挂掉」同一条原则。
83
89
  */
84
- async function resolveEnvContext(repoPath) {
90
+ async function resolveEnvContext(repoPath, { compact = false } = {}) {
85
91
  if (!envContextProvider) return '';
86
92
  try {
87
- const block = await envContextProvider({ repoPath: repoPath || '' });
93
+ const block = await envContextProvider({ repoPath: repoPath || '', compact: !!compact });
88
94
  return typeof block === 'string' ? block : '';
89
95
  } catch (err) {
90
96
  logger.warn(`[workbench] 运行环境上下文注入失败,本次跳过: ${err.message}`);
@@ -917,7 +923,8 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
917
923
  const prefixBlocks = [];
918
924
 
919
925
  if (task.envContext !== false) {
920
- const envBlock = await resolveEnvContext(repoPath);
926
+ // 续聊轮只要精简刷新版(见 resolveEnvContext 的注释)
927
+ const envBlock = await resolveEnvContext(repoPath, { compact: !!resumeSessionId });
921
928
  if (envBlock) prefixBlocks.push(envBlock);
922
929
  }
923
930
 
@@ -1,2 +0,0 @@
1
- import"./rolldown-runtime-C0FnF6B9.js";import{$ as e,C as t,Cn as n,Dn as r,En as i,Fr as a,Jn as o,Jr as s,K as c,Kn as l,Mn as u,Pn as d,Qn as f,Rn as p,S as m,Tn as h,Un as g,Xn as _,br as v,cr as y,ei as b,pr as x,q as S,w as C,wn as w,yn as T}from"./element-plus-AaVXaSqv.js";import{n as E,t as D}from"./_plugin-vue_export-helper-DRfZJqP7.js";import{t as O}from"./SvgIcon-22vrNtgL.js";import{t as k}from"./toolsStore-B3bFcRXP.js";import{s as A,v as j}from"./index-EJrJOx7s.js";import{c as M,d as N,l as P,u as F}from"./agentConversations-17Pw5IQJ.js";p(),x();function I(e,t=680){let r=v(0),i=null;function a(){let t=e.value;return t?(r.value=t.clientWidth,typeof ResizeObserver>`u`||(i=new ResizeObserver(e=>{let t=e[0]?.contentRect?.width;typeof t==`number`&&(r.value=t)}),i.observe(t),!0)):!1}return o(()=>{a()||g(()=>a())}),l(()=>{i?.disconnect(),i=null}),{width:r,narrow:n(()=>r.value>0&&r.value<t)}}p(),x();var L=d({__name:`AgentEngineSelector`,props:{engine:{type:String,required:!0},locked:{type:Boolean,required:!1}},emits:[`select`],setup(r,{expose:i,emit:a}){i();let o=r,s=a,l=k(),u=n(()=>F(o.engine)),d=n(()=>({gai:!0,claude:l.claudeAvailable,opencode:l.opencodeAvailable,codex:l.codexAvailable})),f=v(!1),p=v(null);function h(e){if(!o.locked){if(!d.value[e]){if(!N(e))return;p.value=e,f.value=!0;return}s(`select`,e)}}let g={props:o,emit:s,toolsStore:l,engineName:u,engineAvailability:d,installDialogVisible:f,installTool:p,onCommand:h,get $t(){return E},get ElIcon(){return c},get ElDropdown(){return m},get ElDropdownMenu(){return C},get ElDropdownItem(){return t},get ArrowDown(){return S},get Check(){return e},get AGENT_ENGINE_OPTIONS(){return M},get BUILTIN_ENGINE_ICON(){return P},get isExternalAgentEngine(){return N},SvgIcon:O,TaskExecutorIcon:A,ToolInstallDialog:j};return Object.defineProperty(g,"__isScriptSetup",{enumerable:!1,value:!0}),g}});p(),a();var R={class:`agent-engine`},z=[`disabled`,`title`,`aria-label`],B={class:`agent-engine__name`},V={class:`agent-engine__item`},H={class:`agent-engine__item-name`},U={key:3,class:`agent-engine__item-missing`};function W(e,t,n,a,o,c){return _(),r(T,null,[i(` 单根包一层:父级传下来的 class(Tab 行的 margin-left:auto、面板的 flex:none)
2
- 才能可靠落到这里 —— 多根组件会丢掉 fallthrough class。 `),w(`div`,R,[u(a.ElDropdown,{trigger:`click`,onCommand:a.onCommand},{dropdown:y(()=>[u(a.ElDropdownMenu,null,{default:y(()=>[(_(!0),r(T,null,f(a.AGENT_ENGINE_OPTIONS,e=>(_(),h(a.ElDropdownItem,{key:e.id,command:e.id},{default:y(()=>[w(`span`,V,[a.isExternalAgentEngine(e.id)?(_(),h(a.TaskExecutorIcon,{key:0,executor:e.id,class:`agent-engine__item-icon`},null,8,[`executor`])):(_(),h(a.SvgIcon,{key:1,"icon-class":a.BUILTIN_ENGINE_ICON,class:`agent-engine__item-icon`},null,8,[`icon-class`])),w(`span`,H,b(e.name),1),n.engine===e.id?(_(),h(a.ElIcon,{key:2,class:`agent-engine__item-check`},{default:y(()=>[u(a.Check)]),_:1})):a.engineAvailability[e.id]?i(`v-if`,!0):(_(),r(`span`,U,b(a.$t(`@42BB9:未安装`)),1))])]),_:2},1032,[`command`]))),128))]),_:1})]),default:y(()=>[w(`button`,{type:`button`,class:s([`agent-engine__btn`,{"is-locked":n.locked}]),disabled:n.locked,title:n.locked?a.$t(`@AGENT:本会话的引擎已锁定;要换引擎请新建会话`):a.$t(`@AGENT:新建会话使用的引擎`),"aria-label":a.$t(`@AGENT:智能体引擎`)},[a.isExternalAgentEngine(n.engine)?(_(),h(a.TaskExecutorIcon,{key:0,executor:n.engine,class:`agent-engine__icon`},null,8,[`executor`])):(_(),h(a.SvgIcon,{key:1,"icon-class":a.BUILTIN_ENGINE_ICON,class:`agent-engine__icon`},null,8,[`icon-class`])),w(`span`,B,b(a.engineName),1),u(a.ElIcon,{class:`agent-engine__caret`},{default:y(()=>[u(a.ArrowDown)]),_:1})],10,z)]),_:1}),u(a.ToolInstallDialog,{modelValue:a.installDialogVisible,"onUpdate:modelValue":t[0]||=e=>a.installDialogVisible=e,tool:a.installTool},null,8,[`modelValue`,`tool`])])],2112)}var G=D(L,[[`render`,W],[`__scopeId`,`data-v-c67a4d14`],[`__file`,`C:/workspace/github_workspace/xz333221/zen-gitsync/src/ui/client/src/components/AgentEngineSelector.vue`]]);export{I as n,G as t};