zen-gitsync 2.17.29 → 2.17.30

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 (135) hide show
  1. package/README.md +38 -6
  2. package/package.json +5 -2
  3. package/scripts/release.js +175 -32
  4. package/src/cli/ai/agent.js +1 -1
  5. package/src/cli/ai/extensions.js +1 -1
  6. package/src/cli/ai/mcp.js +6 -5
  7. package/src/cli/ai/mcp.test.js +14 -2
  8. package/src/cli/ai/skills.js +8 -5
  9. package/src/cli/ai/skills.test.js +14 -2
  10. package/src/paths.js +26 -7
  11. package/src/ui/public/assets/AgentEngineSelector-DMqqMLhx.js +1 -0
  12. package/src/ui/public/assets/AgentEngineSelector-DR3r5eQE.css +1 -0
  13. package/src/ui/public/assets/AgentView-B0wVzLSW.js +1 -0
  14. package/src/ui/public/assets/AgentView-D87GB-H9.css +1 -0
  15. package/src/ui/public/assets/{AppVersionBadge-DDYQqoy7.css → AppVersionBadge--5If494j.css} +1 -1
  16. package/src/ui/public/assets/AppVersionBadge-Cc1kJ0D9.js +2 -0
  17. package/src/ui/public/assets/{BranchSelector-F4ibldMN.css → BranchSelector-BTCMYwG0.css} +1 -1
  18. package/src/ui/public/assets/BranchSelector-CLWTSyiv.js +1 -0
  19. package/src/ui/public/assets/CommitForm-C-cL8_vz.css +1 -0
  20. package/src/ui/public/assets/CommitForm-xhy_saX5.js +6 -0
  21. package/src/ui/public/assets/CommonDialog-Bzi5ISqT.css +1 -0
  22. package/src/ui/public/assets/CommonDialog-DeSPG5C-.js +1 -0
  23. package/src/ui/public/assets/EditorView-BsuQfcO4.css +1 -0
  24. package/src/ui/public/assets/EditorView-DOBl6sqW.js +1 -0
  25. package/src/ui/public/assets/FileDiffViewer-B6zy6NiA.css +1 -0
  26. package/src/ui/public/assets/FileDiffViewer-YaOsJZZI.js +14 -0
  27. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
  28. package/src/ui/public/assets/FlowExecutionViewer-BkkeKggj.js +1 -0
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-B95bYCEf.js +21 -0
  30. package/src/ui/public/assets/FlowOrchestrationWorkspace-CM2_9TW3.css +1 -0
  31. package/src/ui/public/assets/IconButton-DI4O8Y_p.js +1 -0
  32. package/src/ui/public/assets/IconButton-W2X09OKH.css +1 -0
  33. package/src/ui/public/assets/LogList-DLxxk9NX.css +1 -0
  34. package/src/ui/public/assets/LogList-DiIWwVCn.js +7 -0
  35. package/src/ui/public/assets/MindmapView-BFK6XzVU.js +1 -0
  36. package/src/ui/public/assets/MindmapView-C4xg3TwX.css +1 -0
  37. package/src/ui/public/assets/MonitorView-BWOQfPtI.css +1 -0
  38. package/src/ui/public/assets/MonitorView-Blsb4ZtW.js +1 -0
  39. package/src/ui/public/assets/ProjectStartupButton-DiN1xs-u.js +1 -0
  40. package/src/ui/public/assets/ProjectStartupButton-VoQqFee4.css +1 -0
  41. package/src/ui/public/assets/RecentDirectoriesChat-DD-qyYN0.js +1 -0
  42. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  43. package/src/ui/public/assets/RemoteManagerDialog-BSOph2h2.css +1 -0
  44. package/src/ui/public/assets/RemoteManagerDialog-D_rJHy9p.js +1 -0
  45. package/src/ui/public/assets/RemoteRepoCard-B98ZUYrp.js +1 -0
  46. package/src/ui/public/assets/RemoteRepoCard-CG9WPcA_.css +1 -0
  47. package/src/ui/public/assets/SourceMapView-CO_BZJKu.css +1 -0
  48. package/src/ui/public/assets/SourceMapView-DCLg5IHN.js +3 -0
  49. package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
  50. package/src/ui/public/assets/SvgIcon-Dg1c0l0q.js +1 -0
  51. package/src/ui/public/assets/TemplateManager-ClQb9yY3.js +1 -0
  52. package/src/ui/public/assets/TemplateManager-DiM3vrbf.css +1 -0
  53. package/src/ui/public/assets/UserInputNode-d-oRXM_J.css +1 -0
  54. package/src/ui/public/assets/UserInputNode-oLCCf_Bn.js +1 -0
  55. package/src/ui/public/assets/WorkbenchView-BljYNp6b.css +1 -0
  56. package/src/ui/public/assets/WorkbenchView-C-yJWhrO.js +20 -0
  57. package/src/ui/public/assets/{_plugin-vue_export-helper-BKeZSroN.js → _plugin-vue_export-helper-OGa67zj-.js} +3 -3
  58. package/src/ui/public/assets/{agentConversations-ChTc-LkQ.js → agentConversations-DaJcYEPn.js} +1 -1
  59. package/src/ui/public/assets/{configStore-fJaic3kE.js → configStore-BrdhjW5U.js} +1 -1
  60. package/src/ui/public/assets/element-plus-Cfu0ZUp9.js +28 -0
  61. package/src/ui/public/assets/flow-mindmap-C_fqj8ZL.js +257 -0
  62. package/src/ui/public/assets/{gitStore-D9oF5nof.js → gitStore-BgvCpppG.js} +1 -1
  63. package/src/ui/public/assets/index-BgO3-PhT.js +29 -0
  64. package/src/ui/public/assets/{index-DVpFh9xE.css → index-BrIl1iaG.css} +1 -1
  65. package/src/ui/public/assets/office-docx-rxiawIim.js +31 -0
  66. package/src/ui/public/assets/{office-excel-CXD9VwgB.js → office-excel-IkgTJC0h.js} +1 -1
  67. package/src/ui/public/assets/office-pptx-CyxidPZ2.js +60 -0
  68. package/src/ui/public/assets/previewSandbox-NWN_GKkA.js +9 -0
  69. package/src/ui/public/assets/{previewSandbox-PYnM6Paz.css → previewSandbox-SqNQvuHv.css} +1 -1
  70. package/src/ui/public/assets/socket-io-TBtw0msS.js +1 -0
  71. package/src/ui/public/assets/{toolsStore-C89takm3.js → toolsStore-DrTCC32p.js} +1 -1
  72. package/src/ui/public/assets/{useThemeObserver-BvSA7RDh.js → useThemeObserver-qsxO0WJ7.js} +1 -1
  73. package/src/ui/public/assets/{vendor-CCrjQw5C.js → vendor-sxw_ye8l.js} +173 -180
  74. package/src/ui/public/assets/vue-flow-Cjr_ADp5.js +39 -0
  75. package/src/ui/public/index.html +24 -24
  76. package/src/ui/server/routes/workbench/agentMarketplace.js +12 -10
  77. package/src/ui/server/routes/workbench/agentMarketplace.test.js +18 -9
  78. package/src/ui/server/routes/workbench/envContext.js +16 -0
  79. package/src/ui/server/routes/workbench/envContext.test.js +17 -0
  80. package/src/ui/server/routes/workbench/index.js +84 -0
  81. package/src/ui/server/routes/workbench/jobImage.js +141 -0
  82. package/src/ui/server/routes/workbench/jobImage.test.js +153 -0
  83. package/src/utils/desktopNotify.js +217 -0
  84. package/src/utils/desktopNotify.test.js +122 -0
  85. package/src/ui/public/assets/AgentEngineSelector-DUf0TfaU.css +0 -1
  86. package/src/ui/public/assets/AgentEngineSelector-Wl7dKYKL.js +0 -2
  87. package/src/ui/public/assets/AgentView-Bebv5xk6.css +0 -1
  88. package/src/ui/public/assets/AgentView-CebQ3545.js +0 -11
  89. package/src/ui/public/assets/AppVersionBadge-jm8ejuZ-.js +0 -2
  90. package/src/ui/public/assets/BranchSelector-CcNILIbg.js +0 -1
  91. package/src/ui/public/assets/CommitForm-1ocxo8Cb.js +0 -6
  92. package/src/ui/public/assets/CommitForm-4kI2csgs.css +0 -1
  93. package/src/ui/public/assets/CommonDialog-DpFTO5cg.js +0 -1
  94. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +0 -1
  95. package/src/ui/public/assets/EditorView-CftLj8-x.css +0 -1
  96. package/src/ui/public/assets/EditorView-CtRHV1Sv.js +0 -37
  97. package/src/ui/public/assets/FileDiffViewer-DXJO-G55.js +0 -15
  98. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +0 -1
  99. package/src/ui/public/assets/FlowExecutionViewer-d4O5Y9Ca.js +0 -1
  100. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
  101. package/src/ui/public/assets/FlowOrchestrationWorkspace-CKLRMeaL.js +0 -21
  102. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +0 -1
  103. package/src/ui/public/assets/IconButton-Dl1cdP_x.js +0 -1
  104. package/src/ui/public/assets/IconButton-RlXqKOeu.css +0 -1
  105. package/src/ui/public/assets/LogList-CdDRC_CO.js +0 -7
  106. package/src/ui/public/assets/LogList-XspUhx-C.css +0 -1
  107. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +0 -1
  108. package/src/ui/public/assets/MindmapView-U84MWPHc.js +0 -2
  109. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +0 -1
  110. package/src/ui/public/assets/MonitorView-spOV0cP7.js +0 -1
  111. package/src/ui/public/assets/ProjectStartupButton-BbAbfFn2.js +0 -1
  112. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +0 -1
  113. package/src/ui/public/assets/RecentDirectoriesChat-BMkc7fS1.css +0 -1
  114. package/src/ui/public/assets/RecentDirectoriesChat-CxNjjgVG.js +0 -3
  115. package/src/ui/public/assets/RemoteManagerDialog-BVm2-Hn4.js +0 -4
  116. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +0 -1
  117. package/src/ui/public/assets/RemoteRepoCard-UHK_hrDS.js +0 -7
  118. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +0 -1
  119. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +0 -1
  120. package/src/ui/public/assets/SourceMapView-PHMr0wt3.js +0 -3
  121. package/src/ui/public/assets/SvgIcon-Dk7WnZLB.js +0 -1
  122. package/src/ui/public/assets/TemplateManager-B3ZNTXnP.js +0 -1
  123. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +0 -1
  124. package/src/ui/public/assets/UserInputNode-DDNSarag.css +0 -1
  125. package/src/ui/public/assets/UserInputNode-DeAD7H0z.js +0 -1
  126. package/src/ui/public/assets/WorkbenchView-DsZ2ONpW.js +0 -143
  127. package/src/ui/public/assets/WorkbenchView-kZ8DoH2l.css +0 -1
  128. package/src/ui/public/assets/element-plus-D0R8BMj4.js +0 -48
  129. package/src/ui/public/assets/flow-mindmap-Doi70WWn.js +0 -257
  130. package/src/ui/public/assets/index-CKxygqdx.js +0 -148
  131. package/src/ui/public/assets/office-docx-O0YXk5gA.js +0 -32
  132. package/src/ui/public/assets/office-pptx-CiF2OlTq.js +0 -74
  133. package/src/ui/public/assets/previewSandbox-21uStCdd.js +0 -9
  134. package/src/ui/public/assets/socket-io-BFqQXqAp.js +0 -1
  135. package/src/ui/public/assets/vue-flow-5Aq2bwoG.js +0 -39
@@ -10,38 +10,38 @@
10
10
  <meta name="theme-color" content="#141820" media="(prefers-color-scheme: dark)" />
11
11
  <title>Zen GitSync</title>
12
12
  <!-- 字体改为自托管(@font-face 见 src/main.css),不再依赖 Google Fonts 外链 -->
13
- <script type="module" crossorigin src="/assets/index-CKxygqdx.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-BgO3-PhT.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
- <link rel="modulepreload" crossorigin href="/assets/element-plus-D0R8BMj4.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/element-plus-Cfu0ZUp9.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-DkJuNcCr.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-CCrjQw5C.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-BKeZSroN.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-Dk7WnZLB.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-Dl1cdP_x.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-fJaic3kE.js">
22
- <link rel="modulepreload" crossorigin href="/assets/socket-io-BFqQXqAp.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-D9oF5nof.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-sxw_ye8l.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-OGa67zj-.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-Dg1c0l0q.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-DI4O8Y_p.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-BrdhjW5U.js">
22
+ <link rel="modulepreload" crossorigin href="/assets/socket-io-TBtw0msS.js">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-BgvCpppG.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/fileIcon-B5p5pGN9.js">
25
- <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-Doi70WWn.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-21uStCdd.js">
27
- <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-BvSA7RDh.js">
28
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-DpFTO5cg.js">
29
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-DXJO-G55.js">
30
- <link rel="modulepreload" crossorigin href="/assets/toolsStore-C89takm3.js">
31
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-B3ZNTXnP.js">
32
- <link rel="modulepreload" crossorigin href="/assets/vue-flow-5Aq2bwoG.js">
25
+ <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-C_fqj8ZL.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-NWN_GKkA.js">
27
+ <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-qsxO0WJ7.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-DeSPG5C-.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-YaOsJZZI.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/toolsStore-DrTCC32p.js">
31
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-ClQb9yY3.js">
32
+ <link rel="modulepreload" crossorigin href="/assets/vue-flow-Cjr_ADp5.js">
33
33
  <link rel="stylesheet" crossorigin href="/assets/element-plus-Qexo5hiM.css">
34
34
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
35
35
  <link rel="stylesheet" crossorigin href="/assets/vendor-Bwl4dT-P.css">
36
- <link rel="stylesheet" crossorigin href="/assets/SvgIcon-CDNsk4-Q.css">
37
- <link rel="stylesheet" crossorigin href="/assets/IconButton-RlXqKOeu.css">
36
+ <link rel="stylesheet" crossorigin href="/assets/SvgIcon-BQ6bR7l0.css">
37
+ <link rel="stylesheet" crossorigin href="/assets/IconButton-W2X09OKH.css">
38
38
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-BMpmHdqh.css">
39
- <link rel="stylesheet" crossorigin href="/assets/previewSandbox-PYnM6Paz.css">
40
- <link rel="stylesheet" crossorigin href="/assets/CommonDialog-w6EmBEXv.css">
41
- <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-n5ea9qMe.css">
42
- <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DO7EPfj8.css">
39
+ <link rel="stylesheet" crossorigin href="/assets/previewSandbox-SqNQvuHv.css">
40
+ <link rel="stylesheet" crossorigin href="/assets/CommonDialog-Bzi5ISqT.css">
41
+ <link rel="stylesheet" crossorigin href="/assets/FileDiffViewer-B6zy6NiA.css">
42
+ <link rel="stylesheet" crossorigin href="/assets/TemplateManager-DiM3vrbf.css">
43
43
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
44
- <link rel="stylesheet" crossorigin href="/assets/index-DVpFh9xE.css">
44
+ <link rel="stylesheet" crossorigin href="/assets/index-BrIl1iaG.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="app"></div>
@@ -22,12 +22,12 @@
22
22
  // 2) **内置精选离线可用**。每个类型的 builtin 组是静态数据,不联网也有内容;
23
23
  // 联网来源失败时用户至少还能看到它们。内置条目的仓库与 npm 包名都经过实际校验,
24
24
  // 不是凭印象写的占位数据。
25
- // 3) **两个安装目标**。
26
- // - project: <cwd>/.claude/skills/<id>/ 与 <cwd>/.mcp.json(项目级,生态通行约定)
25
+ // 3) **两个安装目标**(路径常量都在 src/paths.js,与 g ai 读取端共用同一份)。
26
+ // - project: <cwd>/.zen-gitsync/ai/skills/<id>/ 与 <cwd>/.zen-gitsync/ai/mcp.json
27
27
  // - global : ~/.zen-gitsync/ai/skills/<id>/ 与 ~/.zen-gitsync/ai/mcp.json
28
- // (即「g ai 智能体」,对所有项目生效;路径常量在 src/paths.js)
29
- // 路径与 g ai 的读取端(src/cli/ai/skills.js、src/cli/ai/mcp.js)共用同一份常量,
30
- // 改这里不会出现"装了但读不到"。
28
+ // (即「g ai 智能体」,对所有项目生效)
29
+ // 早先项目级写在 <cwd>/.claude/skills 与 <cwd>/.mcp.json —— 那是别家工具的约定目录,
30
+ // 2026-09-30 按用户要求迁到自家目录,不再读写 .claude。
31
31
  // 4) 所有来自网络的值在落盘前都要过白名单校验(仓库名 / 包名 / 子路径),
32
32
  // 因为这些值最终会变成 git clone 与 npm install 的参数 —— 网络数据不可信。
33
33
  //
@@ -44,7 +44,7 @@ import { execFile, spawn } from 'node:child_process';
44
44
  import { promisify } from 'node:util';
45
45
  import open from 'open';
46
46
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
47
- import { AI_SKILLS_DIR, AI_MCP_FILE } from '../../../../paths.js';
47
+ import { AI_SKILLS_DIR, AI_MCP_FILE, projectSkillsDir, projectMcpFile } from '../../../../paths.js';
48
48
 
49
49
  const execFileAsync = promisify(execFile);
50
50
 
@@ -634,6 +634,7 @@ function normalizeTarget(value) {
634
634
  /**
635
635
  * 把 target + type 翻译成落盘位置。
636
636
  * 项目级与全局级的 skill 目录形状刻意保持一致,读取端才能共用一套解析逻辑。
637
+ * 路径常量统一来自 paths.js —— 这里改了、g ai 那边没改,就会出现"装了但读不到"。
637
638
  */
638
639
  function targetPaths({ type, target, cwd }) {
639
640
  if (target === 'global') {
@@ -641,9 +642,10 @@ function targetPaths({ type, target, cwd }) {
641
642
  ? { root: AI_SKILLS_DIR, file: '', label: 'g ai 智能体' }
642
643
  : { root: '', file: AI_MCP_FILE, label: 'g ai 智能体' };
643
644
  }
645
+ const label = path.basename(cwd) || cwd;
644
646
  return type === 'skill'
645
- ? { root: path.join(cwd, '.claude', 'skills'), file: '', label: path.basename(cwd) || cwd }
646
- : { root: '', file: path.join(cwd, '.mcp.json'), label: path.basename(cwd) || cwd };
647
+ ? { root: projectSkillsDir(cwd), file: '', label }
648
+ : { root: '', file: projectMcpFile(cwd), label };
647
649
  }
648
650
 
649
651
  // ── 已安装扫描 ────────────────────────────────────────────────
@@ -715,10 +717,10 @@ async function installedMcpsIn(file, target) {
715
717
  async function listInstalled({ type, cwd }) {
716
718
  const items = [];
717
719
  if (type === 'skill') {
718
- if (cwd) items.push(...await installedSkillsIn(path.join(cwd, '.claude', 'skills'), 'project'));
720
+ if (cwd) items.push(...await installedSkillsIn(projectSkillsDir(cwd), 'project'));
719
721
  items.push(...await installedSkillsIn(AI_SKILLS_DIR, 'global'));
720
722
  } else {
721
- if (cwd) items.push(...await installedMcpsIn(path.join(cwd, '.mcp.json'), 'project'));
723
+ if (cwd) items.push(...await installedMcpsIn(projectMcpFile(cwd), 'project'));
722
724
  items.push(...await installedMcpsIn(AI_MCP_FILE, 'global'));
723
725
  }
724
726
  return items;
@@ -69,6 +69,13 @@ async function exists(target) {
69
69
  return await fs.stat(target).then(() => true).catch(() => false);
70
70
  }
71
71
 
72
+ // 项目级扩展的落盘位置。这里**写死字面量**而不是 import paths.js 的常量 ——
73
+ // 契约就是"装在哪儿",常量漂了必须在这几条断言上炸出来。
74
+ // (2026-09-30 之前是 <cwd>/.claude/skills 与 <cwd>/.mcp.json,已迁到自家目录)
75
+ const PROJECT_AI = path.join(sandboxProject, '.zen-gitsync', 'ai');
76
+ const PROJECT_SKILLS = path.join(PROJECT_AI, 'skills');
77
+ const PROJECT_MCP = path.join(PROJECT_AI, 'mcp.json');
78
+
72
79
  test('catalog: 只查内置来源时完全离线,并保留按来源分组的形状', async () => {
73
80
  const { call } = harness();
74
81
  const { status, payload } = await call('GET /api/agent/marketplace/catalog', {
@@ -144,7 +151,7 @@ test('sources: 拒绝未知类型', async () => {
144
151
  assert.equal(payload.success, false);
145
152
  });
146
153
 
147
- test('install skill: 项目级落到 <cwd>/.claude/skills,全局级落到 ~/.zen-gitsync/ai/skills', async () => {
154
+ test('install skill: 项目级落到 <cwd>/.zen-gitsync/ai/skills,全局级落到 ~/.zen-gitsync/ai/skills', async () => {
148
155
  const { call } = harness();
149
156
  const content = '---\nname: demo\n description: x\n---\n# demo\n';
150
157
  const body = { type: 'skill', item: { id: 'demo-skill', name: 'demo', content } };
@@ -153,7 +160,9 @@ test('install skill: 项目级落到 <cwd>/.claude/skills,全局级落到 ~/.zen
153
160
  body: { ...body, target: 'project', cwd: sandboxProject },
154
161
  });
155
162
  assert.equal(project.status, 200);
156
- assert.ok(await exists(path.join(sandboxProject, '.claude', 'skills', 'demo-skill', 'SKILL.md')));
163
+ assert.ok(await exists(path.join(PROJECT_SKILLS, 'demo-skill', 'SKILL.md')));
164
+ // 反向自证:项目级不再碰 .claude(那是别家工具的目录,装进去会让人以为有依赖)
165
+ assert.equal(await exists(path.join(sandboxProject, '.claude')), false, '不该在项目里建 .claude');
157
166
 
158
167
  const global = await call('POST /api/agent/marketplace/install', {
159
168
  body: { ...body, target: 'global', cwd: sandboxProject },
@@ -182,7 +191,7 @@ test('install skill: 没有仓库也没有正文时拒绝,不产生空目录', a
182
191
  });
183
192
  assert.equal(status, 400);
184
193
  assert.match(payload.error, /没有可安装的仓库地址/);
185
- assert.equal(await exists(path.join(sandboxProject, '.claude', 'skills', 'useless')), false);
194
+ assert.equal(await exists(path.join(PROJECT_SKILLS, 'useless')), false);
186
195
  });
187
196
 
188
197
  test('install skill: 仓库名与子路径都要过白名单', async () => {
@@ -220,7 +229,7 @@ test('install mcp: 写入目标文件、补齐 name/description,并把 ${project
220
229
  });
221
230
 
222
231
  assert.equal(status, 200);
223
- const file = path.join(sandboxProject, '.mcp.json');
232
+ const file = PROJECT_MCP;
224
233
  const config = JSON.parse(await fs.readFile(file, 'utf8'));
225
234
  const server = config.mcpServers['mcp-fake-fs'];
226
235
  assert.ok(server, '应写入 mcpServers');
@@ -232,12 +241,12 @@ test('install mcp: 写入目标文件、补齐 name/description,并把 ${project
232
241
 
233
242
  test('install mcp: 非法包名被拒绝,且不写出配置文件', async () => {
234
243
  const { call } = harness();
235
- const before = await exists(path.join(sandboxProject, '.mcp.json'));
244
+ const before = await exists(PROJECT_MCP);
236
245
  const { status } = await call('POST /api/agent/marketplace/install', {
237
246
  body: { type: 'mcp', target: 'project', cwd: sandboxProject, item: { id: 'evil', package: 'pkg; rm -rf /' } },
238
247
  });
239
248
  assert.equal(status, 400);
240
- if (!before) assert.equal(await exists(path.join(sandboxProject, '.mcp.json')), false);
249
+ if (!before) assert.equal(await exists(PROJECT_MCP), false);
241
250
  });
242
251
 
243
252
  test('install mcp: 只有远程端点时用 mcp-remote 桥接,而不是报错或装出一个无效配置', async () => {
@@ -291,7 +300,7 @@ test('uninstall: 只删指定目标的那一份,另一份保留', async () => {
291
300
  query: { target: 'project', cwd: sandboxProject },
292
301
  });
293
302
  assert.equal(status, 200);
294
- assert.equal(await exists(path.join(sandboxProject, '.claude', 'skills', 'demo-skill')), false);
303
+ assert.equal(await exists(path.join(PROJECT_SKILLS, 'demo-skill')), false);
295
304
  assert.ok(await exists(path.join(AI_SKILLS_DIR, 'demo-skill', 'SKILL.md')), '全局那份不应被连坐删掉');
296
305
  });
297
306
 
@@ -302,7 +311,7 @@ test('uninstall: MCP 从配置里摘掉对应键,保留其他 server', async ()
302
311
  query: { target: 'project', cwd: sandboxProject },
303
312
  });
304
313
  assert.equal(status, 200);
305
- const config = JSON.parse(await fs.readFile(path.join(sandboxProject, '.mcp.json'), 'utf8'));
314
+ const config = JSON.parse(await fs.readFile(PROJECT_MCP, 'utf8'));
306
315
  assert.equal(Object.prototype.hasOwnProperty.call(config.mcpServers, 'mcp-fake-fs'), false);
307
316
  assert.ok(await exists(AI_MCP_FILE), '全局配置文件应保持不变');
308
317
  });
@@ -327,7 +336,7 @@ test('uninstall: 未安装的条目返回 404', async () => {
327
336
  assert.equal(status, 404);
328
337
  });
329
338
 
330
- test('install: 项目不存在时拒绝,不会把 .claude/skills 建到奇怪的地方', async () => {
339
+ test('install: 项目不存在时拒绝,不会把 .zen-gitsync/ai/skills 建到奇怪的地方', async () => {
331
340
  const { call } = harness();
332
341
  const missing = path.join(sandboxProject, 'no-such-dir');
333
342
  const { status, payload } = await call('POST /api/agent/marketplace/install', {
@@ -153,6 +153,22 @@ export function buildEnvContextBlock({
153
153
  lines.push('');
154
154
  }
155
155
 
156
+ // ── 对话流渲染:正文里嵌的图 ────────────────────────────────────────────
157
+ //
158
+ // 与「克隆优先 SSH」同类:行为约定、不需要取数,所以取不到 board 时也照给。
159
+ //
160
+ // 为什么非写不可:执行器是在本机干活的,让它"展示一张图"时它会去 Read 那张图、
161
+ // 然后**在正文里贴一条本机路径**(或者把路径塞进代码块)—— 而对话流是 markdown
162
+ // 渲染,屏幕上只有一行字(用户实测 2026-09-30:图读出来了、描述得挺准、就是没有图)。
163
+ // 这条告诉它写成 `![](路径)`;后端据此开了一个「仓库内的图片」端点
164
+ // (workbench/jobImage.js),前端负责把路径重写成那个 URL(utils/localImageSrc.ts)。
165
+ lines.push(compact
166
+ ? '对话流渲染:正文里写 `![说明](图片绝对路径)` 用户才能看到图(图片须在本任务仓库内);只贴路径,用户看到的是一行字。'
167
+ : '对话流渲染:想让用户**看到**图,就在回答正文里写 Markdown 图片 `![说明](图片绝对路径)` —— '
168
+ + '图片须位于本任务仓库内(把截图 / 图表存进去再引用),界面会自动加载渲染。'
169
+ + '只贴路径、或把路径放进代码块,用户看到的只是一行字。');
170
+ lines.push('');
171
+
156
172
  if (compact) {
157
173
  // 引导语替掉整段用户偏好,但**点名**那条行为约定:偏好本身不重述(前文有),
158
174
  // 可"有这么一条"得让模型看得见,否则克隆仓库时它会退回 https。
@@ -252,6 +252,23 @@ test('注入块带上「克隆优先 SSH」这条用户偏好', () => {
252
252
  assert.match(block, /Permission denied \(publickey\)/);
253
253
  });
254
254
 
255
+ // ── 对话流渲染:正文里嵌的图 ──────────────────────────────────────────────
256
+ //
257
+ // 执行器"展示一张图"的默认写法是贴一条本机路径(它 Read 了那张图,描述得也挺准),
258
+ // 而对话流是 markdown 渲染 —— 用户屏幕上只有一行字。这条提示是唯一能让 Agent 知道
259
+ // "写 ![](路径) 才看得见"的地方,所以两种模式都得带上,且必须点明"仓库内"这个前提
260
+ // (后端端点只服务仓库内的图片,写成仓库外的路径会 403)。
261
+ test('注入块要求 Agent 用 markdown 图片语法展示图,且说明图片须在仓库内', () => {
262
+ const board = boardOf({ recentDirs: ['D:\\ws\\a'], tasks: [] });
263
+ for (const [name, block] of [['完整版', build({ board })], ['精简版', build({ board, compact: true })]]) {
264
+ assert.match(block, /对话流渲染/, `${name}缺少对话流渲染提示`);
265
+ assert.match(block, /!\[说明\]\(图片绝对路径\)/, `${name}没给出可照抄的写法`);
266
+ assert.match(block, /仓库内/, `${name}没说清图片必须落在仓库内`);
267
+ // 只贴路径这个反例要点出来:模型最可能的做法就是它
268
+ assert.match(block, /只贴路径/, `${name}没点出"只贴路径看不到图"`);
269
+ }
270
+ });
271
+
255
272
  test('真相源清单来自唯一的 TRUTH_FILES(四个路径一个不落,且不多写)', () => {
256
273
  const block = build({ board: boardOf({ recentDirs: ['D:\\ws\\a'], tasks: [] }) });
257
274
  const listed = block.split('\n').filter(l => l.startsWith('- ') && l.includes(' —— ') && l.includes('json'));
@@ -74,6 +74,12 @@ import {
74
74
  findStagingFile,
75
75
  cleanupDispatchStaging,
76
76
  } from './attachmentUtils.js';
77
+ import {
78
+ resolveJobImagePath,
79
+ isInsideRoot,
80
+ IMAGE_PATH_ERRORS,
81
+ IMAGE_PATH_STATUS,
82
+ } from './jobImage.js';
77
83
  import {
78
84
  DEFAULT_INSTRUCTION,
79
85
  readInstruction,
@@ -933,6 +939,84 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
933
939
  res.json({ success: true, job: serializeJob(j, taskMap) });
934
940
  }));
935
941
 
942
+ // GET /api/workbench/jobs/:id/image?path=… —— 正文内嵌图片(模型写 ![说明](本机路径))
943
+ //
944
+ // 为什么要有这个端点:执行器在本机干活,"展示一张图"最自然的写法是贴一条本机路径,
945
+ // 而对话流是 markdown 渲染 —— `<img src="c:\ws\repo\docs\a.png">` 在浏览器里永远是
946
+ // 裂图。前端把这类路径重写成这个 URL(utils/localImageSrc.ts,两处对话流共用)。
947
+ //
948
+ // 权限边界 =「这个 job 所属仓库内的图片文件」(判定见 jobImage.js):
949
+ // · 后缀过 IMAGE_EXTS 白名单;路径必须落在仓库根内(`..` 穿越在那里就挡掉了);
950
+ // · 这里**再按 realpath 校验一次** —— lexical 判定挡不住仓库里一个指向外部的
951
+ // 符号链接,而仓库内容很可能是从网上 clone 来的;
952
+ // · 响应带 nosniff + CSP(sandbox):同上前提下,一份恶意 SVG 被直接在浏览器里
953
+ // 打开时,它的脚本不能跑在本应用的源上(<img> 里本来就不会执行,这里防的是直接访问)。
954
+ app.get('/api/workbench/jobs/:id/image', asyncRoute(async (req, res) => {
955
+ const id = req.params.id;
956
+ // 优先内存;退回「磁盘历史 ∪ 别的实例正在跑」的合并表 —— 看板上能看到、日志也能
957
+ // 在本实例渲染的 job,它的图不该因为"不是这个进程起的"就裂掉(与 GET /jobs/:id 同源)。
958
+ let job = jobs.get(id);
959
+ if (!job) {
960
+ await refreshJobsFromDisk();
961
+ job = mergedJobs().get(id);
962
+ }
963
+ if (!job) throw new HttpError(404, 'job 不存在');
964
+
965
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
966
+ const task = (tasksData.tasks || []).find(t => t.id === job.taskId);
967
+ const root = resolveTaskRepoPath(
968
+ task,
969
+ typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '',
970
+ );
971
+
972
+ const resolved = resolveJobImagePath({ root, requested: req.query.path });
973
+ if (!resolved.ok) {
974
+ throw new HttpError(
975
+ IMAGE_PATH_STATUS[resolved.reason] || 400,
976
+ IMAGE_PATH_ERRORS[resolved.reason] || '图片路径不合法',
977
+ );
978
+ }
979
+
980
+ let realFile;
981
+ try {
982
+ realFile = await fsp.realpath(resolved.absPath);
983
+ } catch {
984
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
985
+ }
986
+ let realRoot;
987
+ try {
988
+ realRoot = await fsp.realpath(root);
989
+ } catch {
990
+ realRoot = root; // 仓库根都没了的话,下面那次 stat 也会失败,不必在这里另报一种错
991
+ }
992
+ if (!isInsideRoot(realRoot, realFile)) {
993
+ throw new HttpError(IMAGE_PATH_STATUS['outside-root'], IMAGE_PATH_ERRORS['outside-root']);
994
+ }
995
+
996
+ let stat;
997
+ try {
998
+ stat = await fsp.stat(realFile);
999
+ } catch {
1000
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
1001
+ }
1002
+ if (!stat.isFile()) throw new HttpError(404, '路径不是一个文件');
1003
+ // 与附件同一个上限:正文里嵌一张 20MB 以上的图只会把页面拖死
1004
+ if (stat.size > MAX_IMAGE_BYTES) {
1005
+ throw new HttpError(413, `图片超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB,未加载`);
1006
+ }
1007
+
1008
+ // mime 按**后缀**给(不读文件头):后缀已过白名单,且这条内容只喂给 <img>
1009
+ res.set('Content-Type', resolved.mime);
1010
+ res.set('Content-Length', String(stat.size));
1011
+ res.set('Content-Disposition', 'inline');
1012
+ res.set('Cache-Control', 'private, max-age=300');
1013
+ res.set('X-Content-Type-Options', 'nosniff');
1014
+ res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
1015
+ const stream = fs.createReadStream(realFile);
1016
+ stream.on('error', () => res.end());
1017
+ stream.pipe(res);
1018
+ }));
1019
+
936
1020
  app.delete('/api/workbench/jobs/:id', asyncRoute(async (req, res) => {
937
1021
  const id = req.params.id;
938
1022
  let removed = false;
@@ -0,0 +1,141 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 正文内嵌图片的路径判定(模型在回答里写 `![说明](图片路径)`)。
16
+ //
17
+ // ── 为什么需要服务端做中转 ────────────────────────────────────────────────
18
+ //
19
+ // 执行器(claude / opencode / codex)是在本机干活的,"展示一张图"对它来说最自然的
20
+ // 写法就是把刚截的图 / 刚读的 png 的**本机路径**贴出来。而对话流是 markdown 渲染:
21
+ // `<img src="c:\ws\repo\docs\a.png">` 在浏览器里永远是裂图 —— 那个地址不在页面能
22
+ // 够到的世界里。用户实测(2026-09-30):让 Agent"展示一张图片",它把截图读进上下文、
23
+ // 描述得挺准,但屏幕上只有一行字和一条路径。
24
+ //
25
+ // 于是:前端把这类本地路径重写成 `/api/workbench/jobs/:id/image?path=…`
26
+ // (utils/localImageSrc.ts),服务端按「这个 job 所属仓库」去取文件 —— 本文件是
27
+ // 那个端点的**路径判定**部分,读盘在 index.js 的路由里。
28
+ //
29
+ // ── 权限边界刻意收在"仓库内的图片" ────────────────────────────────────────
30
+ //
31
+ // · 后缀必须过 IMAGE_EXTS 白名单(附件用的同一份,别再另起一份 —— 两份白名单
32
+ // 分叉时不会报错,只会让某一类图在正文里裂、在附件里正常);
33
+ // · 解析后的绝对路径必须落在仓库根内(`..` 穿越在这里被挡掉)。
34
+ //
35
+ // 判定写成**纯函数**(不碰 fs):跨平台路径比较(盘符大小写、`..`、UNC、混用斜杠)
36
+ // 是最容易写错又最难在集成测试里覆盖的一块,单独可测。真正的读盘那一步还会按
37
+ // realpath 再校验一次(挡仓库里指向外部的符号链接),见 index.js。
38
+
39
+ import path from 'path';
40
+ import { IMAGE_EXTS, mimeForExt } from './attachmentUtils.js';
41
+
42
+ /**
43
+ * 失败原因 → HTTP 状态码。
44
+ * 放在这里而不是路由里 switch:端点只负责把 reason 映射出去,
45
+ * 免得"哪些原因算 4xx 的哪一种"在两处各写一遍。
46
+ */
47
+ export const IMAGE_PATH_STATUS = {
48
+ 'no-root': 400,
49
+ 'empty': 400,
50
+ 'bad-ext': 415,
51
+ 'outside-root': 403,
52
+ };
53
+
54
+ /** 失败原因 → 给前端/用户的文案 */
55
+ export const IMAGE_PATH_ERRORS = {
56
+ 'no-root': '无法确定任务所属仓库路径',
57
+ 'empty': 'path 不能为空',
58
+ 'bad-ext': '只支持图片文件(png / jpg / jpeg / gif / webp / bmp / svg)',
59
+ 'outside-root': '图片必须位于任务所属仓库内',
60
+ };
61
+
62
+ /**
63
+ * 规整模型给出来的路径写法。
64
+ *
65
+ * 模型写路径的几种常见包裹方式都要认(否则同一条路径有时能渲染、有时不能):
66
+ * `<c:\ws\a.png>`(markdown 允许尖括号包住含空格的目标)、`` `c:\ws\a.png` ``、
67
+ * `"c:\ws\a.png"`、`file:///c:/ws/a.png`。
68
+ * 返回空串表示"这个值不值得再往下解析"(空 / 含 NUL)。
69
+ *
70
+ * @param {unknown} requested
71
+ * @returns {string} 规整后的路径;不可用时为空串
72
+ */
73
+ export function normalizeImagePath(requested) {
74
+ let p = typeof requested === 'string' ? requested.trim() : '';
75
+ if (!p) return '';
76
+ // NUL 字节是路径里的经典绕过手法(`a.png\0.txt`),直接判死
77
+ if (p.includes('\0')) return '';
78
+ for (const [open, close] of [['<', '>'], ['`', '`'], ['"', '"'], ["'", "'"]]) {
79
+ if (p.length > open.length + close.length && p.startsWith(open) && p.endsWith(close)) {
80
+ p = p.slice(open.length, p.length - close.length).trim();
81
+ break;
82
+ }
83
+ }
84
+ if (/^file:\/\//i.test(p)) {
85
+ p = p.replace(/^file:\/\//i, '');
86
+ // file:///C:/a.png → /C:/a.png,那个多余的斜杠会让 win32 把盘符当相对目录
87
+ if (/^\/[a-zA-Z]:[\\/]/.test(p)) p = p.slice(1);
88
+ }
89
+ return p.trim();
90
+ }
91
+
92
+ /** 路径后缀(小写,不含点);没有后缀返回空串 */
93
+ export function imageExtOf(p) {
94
+ const m = String(p || '').toLowerCase().match(/\.([a-z0-9]+)$/);
95
+ return m ? m[1] : '';
96
+ }
97
+
98
+ /**
99
+ * `abs` 是否真的落在 `root` 目录**内部**(不含 root 自己)。
100
+ *
101
+ * 用 `path.relative` 而不是 `startsWith(root)`:字符串前缀会把 `D:\ws\repo-old`
102
+ * 当成 `D:\ws\repo` 里的路径。win32 上 relative 还会把盘符大小写归一,
103
+ * 所以 `d:\ws\repo` 与 `D:\WS\REPO\x.png` 能正确判成父子关系。
104
+ */
105
+ export function isInsideRoot(root, abs) {
106
+ const r = path.resolve(String(root || ''));
107
+ const a = path.resolve(String(abs || ''));
108
+ if (!r || !a) return false;
109
+ const rel = path.relative(r, a);
110
+ if (!rel) return false; // 等于根目录本身 —— 那是目录不是文件
111
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
112
+ }
113
+
114
+ /**
115
+ * 把请求里的 path 解析成「仓库内的一张图片」。
116
+ *
117
+ * 相对路径按仓库根解析(模型常常写 `docs/screenshots/a.png` —— 它的 cwd 就是仓库根,
118
+ * 对它来说这就是一个够用的地址;到我们这边得补上根)。
119
+ *
120
+ * @param {object} input
121
+ * @param {string} input.root 任务所属仓库绝对路径
122
+ * @param {unknown} input.requested 请求里的原始 path(查询串已由 express 解码)
123
+ * @returns {{ok: true, absPath: string, ext: string, mime: string}
124
+ * | {ok: false, reason: keyof typeof IMAGE_PATH_ERRORS}}
125
+ */
126
+ export function resolveJobImagePath({ root = '', requested = '' } = {}) {
127
+ const base = typeof root === 'string' ? root.trim() : '';
128
+ if (!base) return { ok: false, reason: 'no-root' };
129
+
130
+ const rel = normalizeImagePath(requested);
131
+ if (!rel) return { ok: false, reason: 'empty' };
132
+
133
+ const ext = imageExtOf(rel);
134
+ if (!ext || !IMAGE_EXTS.has(ext)) return { ok: false, reason: 'bad-ext' };
135
+
136
+ // 绝对路径原样用,相对路径挂到仓库根下
137
+ const absPath = path.resolve(base, rel);
138
+ if (!isInsideRoot(base, absPath)) return { ok: false, reason: 'outside-root' };
139
+
140
+ return { ok: true, absPath, ext, mime: mimeForExt(ext) };
141
+ }