zen-gitsync 2.17.8 → 2.17.10

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 (112) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +210 -58
  4. package/src/cli/ai/context.js +58 -0
  5. package/src/cli/ai/context.test.js +117 -0
  6. package/src/cli/ai/extensions.js +131 -0
  7. package/src/cli/ai/extensions.test.js +158 -0
  8. package/src/cli/ai/mcp.js +498 -0
  9. package/src/cli/ai/mcp.test.js +275 -0
  10. package/src/cli/ai/runtime.test.js +32 -0
  11. package/src/cli/ai/skills.js +207 -0
  12. package/src/cli/ai/skills.test.js +126 -0
  13. package/src/cli/ai/termui.js +1 -0
  14. package/src/cli/ai/tools.js +94 -3
  15. package/src/cli/ai/tools.test.js +82 -0
  16. package/src/cli/ai/transport.js +18 -4
  17. package/src/cli/ai/turn.js +21 -5
  18. package/src/config.js +27 -3
  19. package/src/paths.js +11 -0
  20. package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
  21. package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
  22. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
  23. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
  24. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
  25. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
  26. package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
  27. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
  28. package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
  30. package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
  31. package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
  32. package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
  33. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
  35. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
  36. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
  37. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
  38. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
  39. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
  41. package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
  44. package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
  45. package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
  46. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
  47. package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
  48. package/src/ui/public/assets/index-CW3xs-af.css +1 -0
  49. package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
  50. package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
  51. package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
  52. package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
  53. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  54. package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
  55. package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
  56. package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
  57. package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
  58. package/src/ui/public/index.html +17 -17
  59. package/src/ui/server/index.js +4 -0
  60. package/src/ui/server/routes/config.js +16 -3
  61. package/src/ui/server/routes/exec.js +19 -11
  62. package/src/ui/server/routes/fileOpen.js +142 -2
  63. package/src/ui/server/routes/remoteRepos.js +604 -0
  64. package/src/ui/server/routes/remoteRepos.test.js +386 -0
  65. package/src/ui/server/routes/workbench/agentChat.js +86 -180
  66. package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
  67. package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
  68. package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
  69. package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
  70. package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
  71. package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
  72. package/src/ui/server/routes/workbench/jobStore.js +17 -3
  73. package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
  74. package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
  75. package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
  76. package/src/ui/server/routes/workbench/projectTool.js +198 -0
  77. package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
  78. package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
  79. package/src/ui/server/routes/workbench/taskRunner.js +269 -12
  80. package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
  81. package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
  82. package/src/utils/shellPath.js +253 -0
  83. package/src/utils/shellPath.test.js +221 -0
  84. package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
  85. package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
  86. package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
  87. package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
  88. package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
  89. package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
  90. package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
  91. package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
  93. package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
  94. package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
  95. package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
  96. package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
  97. package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
  98. package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
  99. package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
  100. package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
  101. package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
  102. package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
  103. package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
  104. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
  105. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
  106. package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
  107. package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
  108. package/src/ui/public/assets/index-cApp6w0b.css +0 -1
  109. package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
  110. package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
  111. package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
  112. package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
@@ -40,7 +40,7 @@ import {
40
40
  } from './shared.js';
41
41
 
42
42
  // 进程表:记录每个子任务的运行状态
43
- // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt, child, output, thinking, claudeSessionId }
43
+ // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt, child, output, thinking, claudeSessionId, toolCalls }
44
44
  export const jobs = new Map();
45
45
 
46
46
  // 事件总线:SSE /api/workbench/events 监听 'event',路由层用 publish() 推送
@@ -164,9 +164,17 @@ export function serializeJob(j, taskMap) {
164
164
  const { child, ...rest } = j;
165
165
  const t = taskMap ? taskMap.get(rest.taskId) : null;
166
166
  const sub = t && Array.isArray(t.subtasks) ? t.subtasks.find(s => s.id === rest.subId) : null;
167
+ // size 是保留策略(maxSizeMB)的计价口径,工具调用也会占体积,必须算进去,
168
+ // 否则一份全是工具流水的 job 会被当成"很小"而留下来。
169
+ const toolCallsSize = Array.isArray(rest.toolCalls)
170
+ ? rest.toolCalls.reduce((sum, c) => sum + (typeof c?.arguments === 'string' ? c.arguments.length : 0)
171
+ + (typeof c?.result === 'string' ? c.result.length : 0)
172
+ + (typeof c?.argsPreview === 'string' ? c.argsPreview.length : 0), 0)
173
+ : 0;
167
174
  const size = ((rest.prompt || '').length
168
175
  + (rest.output || '').length
169
- + (rest.thinking || '').length);
176
+ + (rest.thinking || '').length
177
+ + toolCallsSize);
170
178
  return {
171
179
  ...rest,
172
180
  taskTitle: t ? t.title : '',
@@ -252,7 +260,10 @@ export async function hydrateJobs() {
252
260
  }
253
261
  // 旧版本可能没 size 字段;补齐以兼容历史文件
254
262
  if (typeof j.size !== 'number') {
255
- j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length);
263
+ const toolSize = Array.isArray(j.toolCalls)
264
+ ? j.toolCalls.reduce((sum, c) => sum + (c?.arguments || '').length + (c?.result || '').length, 0)
265
+ : 0;
266
+ j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length + toolSize);
256
267
  }
257
268
  jobs.set(j.id, j);
258
269
  }
@@ -318,6 +329,9 @@ export function snapshotJobs() {
318
329
  // 本轮用的执行器(claude | opencode)。前端对话区助手名 / 日志详情按它显示;
319
330
  // 加字段时记得同步这里 —— 白名单投影会把没列出的字段静默剥掉。
320
331
  agent: j.agent || null,
332
+ // 工具调用流水({id,name,argsPreview,arguments,result,status,error}[])。
333
+ // 前端靠它画工具块;刷新页面 / 换实例后仍然可见,所以必须过白名单。
334
+ toolCalls: Array.isArray(j.toolCalls) ? j.toolCalls : [],
321
335
  // 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
322
336
  claudeSessionId: j.claudeSessionId || null
323
337
  }));
@@ -299,6 +299,8 @@ export function buildRunningAgents({ jobs = [], tasks = [] } = {}) {
299
299
  * 一条 job 产出两条记录(派发 / 收尾),这是它的真实生命周期:
300
300
  * dispatch ← startedAt 存在
301
301
  * done / error / cancelled ← endedAt 存在,按 status 分
302
+ * 唯一例外:job 是由一条指令直接带起来的,那次起跑并进指令行(见
303
+ * absorbInstructionDispatches)—— 否则派发行和指令行会同秒同文地出现两遍。
302
304
  * 只回结构化字段,句子交给前端 i18n。
303
305
  *
304
306
  * @param {{ jobs?: object[], tasks?: object[], instructions?: object[] }} input
@@ -367,6 +369,44 @@ export function buildActivityFeed({ jobs = [], tasks = [], instructions = [] } =
367
369
  }
368
370
 
369
371
  // 时间缺失的排最后(排序键用空串,天然沉底)
370
- rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
371
- return rows.slice(0, MAX_ACTIVITY_ENTRIES);
372
+ return absorbInstructionDispatches(rows)
373
+ .sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
374
+ .slice(0, MAX_ACTIVITY_ENTRIES);
375
+ }
376
+
377
+ /**
378
+ * 把「由指令直接带起来的那一次起跑」并进指令行,不再单独出「派发」。
379
+ *
380
+ * 派发行的正文是「{taskTitle}」派发至 {project},而任务标题就是指令首行
381
+ * (建任务时 `text.split('\n')[0]` 取的)—— 于是同一秒里会出现两行几乎一字不差:
382
+ * 一行标「用户派发」、一行标「派发」,看着像日志重复打印了一遍。
383
+ * 落点信息本来也没丢:指令行自己就带 projectName,前端还会补上落点与依据。
384
+ *
385
+ * 只吸收**每条指令之后的第一次**起跑,不是吸收这个任务的所有起跑:
386
+ * 同一个任务被手动重跑时,后面那些派发行没有指令与之对应,抹掉就会出现
387
+ * 「连着两条完成,却找不到第二次是从哪儿开始的」。
388
+ *
389
+ * 手动点执行产生的 job(没有对应指令)一概不动 —— 那种情况下派发行是**唯一**
390
+ * 能说明"它是什么时候跑起来的"的记录。
391
+ */
392
+ function absorbInstructionDispatches(rows) {
393
+ const startsByTask = new Map();
394
+ for (const r of rows) {
395
+ if (r.kind !== 'dispatch' || !r.taskId) continue;
396
+ if (!startsByTask.has(r.taskId)) startsByTask.set(r.taskId, []);
397
+ startsByTask.get(r.taskId).push(r);
398
+ }
399
+ for (const list of startsByTask.values()) {
400
+ list.sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')));
401
+ }
402
+
403
+ const absorbed = new Set();
404
+ for (const r of rows) {
405
+ if (r.kind !== 'user' || !r.taskId || !r.at) continue;
406
+ const start = (startsByTask.get(r.taskId) || []).find(
407
+ x => !absorbed.has(x.id) && String(x.at || '') >= String(r.at)
408
+ );
409
+ if (start) absorbed.add(start.id);
410
+ }
411
+ return absorbed.size ? rows.filter(r => !absorbed.has(r.id)) : rows;
372
412
  }
@@ -332,6 +332,12 @@ export function decorateTaskForBoard(task, jobsForTask = []) {
332
332
  attachmentCount: Array.isArray(task.attachments) ? task.attachments.length : 0,
333
333
  runningJobs: jobs.filter(j => j && (j.status === 'running' || j.status === 'pending')).length,
334
334
  lastJobStatus: last ? last.status : null,
335
+ // 最近一条 job 的结束时间 = 这张卡片"跑完"的时刻。
336
+ // 看板的「已完成」列要按完成时间倒序排(最新完成的在最上边),而 updatedAt 撑不起这个排序:
337
+ // 简单任务的执行完成只写 jobs.json,tasks.json 里的 updatedAt 一直停在创建时间
338
+ //(见 taskRunner.persistTaskAfterRun —— 它只有复杂任务才走),
339
+ // 于是十几张卡片的时间会全是创建时刻,"最新完成的"根本排不出来。
340
+ lastJobEndedAt: last ? (last.endedAt || last.startedAt || null) : null,
335
341
  createdAt: task.createdAt || null,
336
342
  updatedAt: task.updatedAt || null,
337
343
  };
@@ -215,6 +215,29 @@ test('decorateTaskForBoard: 只回卡片需要的字段', () => {
215
215
  assert.equal(card.subtaskErrorCount, 1);
216
216
  assert.equal(card.attachmentCount, 1);
217
217
  assert.equal(card.runningJobs, 0);
218
+ // 没跑过就没有完成时间——看板「已完成」列的排序靠它,空值必须显式是 null
219
+ // 而不是 undefined(前端 doneAt 的回退链要能一路退到 updatedAt)
220
+ assert.equal(card.lastJobEndedAt, null);
221
+ });
222
+
223
+ test('decorateTaskForBoard: lastJobEndedAt 取最近一条 job 的结束时间(已完成列排序用)', () => {
224
+ const jobs = [
225
+ job('j1', 't1', 'done', '2026-01-01T00:00:00Z', '2026-01-01T00:10:00Z'),
226
+ job('j2', 't1', 'done', '2026-01-03T00:00:00Z', '2026-01-03T00:20:00Z'),
227
+ job('j3', 't1', 'error', '2026-01-02T00:00:00Z', '2026-01-02T00:05:00Z'),
228
+ ];
229
+ const base = { id: 't1', title: '', desc: '', subtasks: [], createdAt: '2025-12-31T00:00:00Z' };
230
+
231
+ // 只有 j2 是"最近"的,即使它的结束时间排在 startedAt 序里也是最后
232
+ assert.equal(decorateTaskForBoard(base, jobs).lastJobEndedAt, '2026-01-03T00:20:00Z');
233
+
234
+ // 简单任务执行完成不写 tasks.json 的 updatedAt,所以完成时间只能由 job 给——
235
+ // 这条断言守的就是"卡片时间不能退化成创建时间"这个坑
236
+ assert.equal(decorateTaskForBoard(base, jobs).updatedAt, null);
237
+
238
+ // 还在跑(没有 endedAt)时退到 startedAt,卡片至少有个时间可显示
239
+ const running = [job('j9', 't1', 'running', '2026-02-01T00:00:00Z')];
240
+ assert.equal(decorateTaskForBoard(base, running).lastJobEndedAt, '2026-02-01T00:00:00Z');
218
241
  });
219
242
 
220
243
  // ── 编排状态与活动流 ────────────────────────────────────────────────
@@ -258,6 +281,53 @@ test('buildActivityFeed: job 起止合成两条记录,与人类指令一起按
258
281
  assert.equal(feed[0].instructionStatus, 'accepted');
259
282
  });
260
283
 
284
+ test('buildActivityFeed: 指令带起来的起跑并进指令行,不再重复出一遍「派发」', () => {
285
+ const jobs = [{
286
+ id: 'j1', taskId: 't1', status: 'done',
287
+ startedAt: '2026-01-01T00:00:01Z', endedAt: '2026-01-01T00:05:00Z',
288
+ }];
289
+ // 任务标题就是指令首行,所以派发行和指令行本来长得一模一样
290
+ const tasks = [{ id: 't1', title: '把这些重构一下', projectPath: 'D:\\ws\\proj-a', subtasks: [] }];
291
+ const instructions = [{
292
+ id: 'i1', text: '把这些重构一下', taskId: 't1', projectPath: 'D:\\ws\\proj-a',
293
+ at: '2026-01-01T00:00:00Z', status: 'accepted',
294
+ }];
295
+
296
+ const feed = buildActivityFeed({ jobs, tasks, instructions });
297
+ assert.deepEqual(feed.map(r => r.kind), ['done', 'user']);
298
+ // 落点没丢:指令行自己带着项目名,前端据此渲染「落点「proj-a」」
299
+ assert.equal(feed[1].projectName, 'proj-a');
300
+ });
301
+
302
+ test('buildActivityFeed: 只吸收指令后的首次起跑,手动重跑仍保留派发行', () => {
303
+ const jobs = [
304
+ { id: 'j1', taskId: 't1', status: 'done', startedAt: '2026-01-01T00:00:01Z', endedAt: '2026-01-01T00:05:00Z' },
305
+ { id: 'j2', taskId: 't1', status: 'done', startedAt: '2026-01-01T00:10:00Z', endedAt: '2026-01-01T00:15:00Z' },
306
+ ];
307
+ const tasks = [{ id: 't1', title: '跑一下', projectPath: 'D:\\ws\\proj-a', subtasks: [] }];
308
+ const instructions = [{
309
+ id: 'i1', text: '跑一下', taskId: 't1', projectPath: 'D:\\ws\\proj-a',
310
+ at: '2026-01-01T00:00:00Z', status: 'accepted',
311
+ }];
312
+
313
+ const feed = buildActivityFeed({ jobs, tasks, instructions });
314
+ assert.deepEqual(feed.map(r => r.kind), ['done', 'dispatch', 'done', 'user']);
315
+ assert.equal(feed[1].jobId, 'j2'); // 留下的是第二次(重跑)那次
316
+ });
317
+
318
+ test('buildActivityFeed: 指令还没跑起来时,起跑行照旧保留给别的 job', () => {
319
+ // 指令建了任务但没执行(调度暂停)——此时该任务的任何起跑都不是它带起来的
320
+ const jobs = [{ id: 'j1', taskId: 't1', status: 'done', startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:05:00Z' }];
321
+ const tasks = [{ id: 't1', title: '手跑的', projectPath: 'D:\\a', subtasks: [] }];
322
+ const instructions = [{
323
+ id: 'i1', text: '手跑的', taskId: 't1', projectPath: 'D:\\a',
324
+ at: '2026-01-01T00:20:00Z', status: 'created',
325
+ }];
326
+
327
+ const feed = buildActivityFeed({ jobs, tasks, instructions });
328
+ assert.deepEqual(feed.map(r => r.kind), ['user', 'done', 'dispatch']);
329
+ });
330
+
261
331
  test('buildActivityFeed: 失败与取消分别落成 error / cancelled', () => {
262
332
  const jobs = [
263
333
  { id: 'j1', taskId: 't1', subId: 's1', status: 'error', startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:01:00Z', error: 'boom' },
@@ -0,0 +1,198 @@
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
+ // g ai 的 list_projects 工具:数据源与格式化。
16
+ //
17
+ // 解决什么问题:GUI 里最近项目面板早就把"哪些项目落后了几个提交"算好了,但那份数据
18
+ // 只走 HTTP 给前端,g ai 的上下文里一个字段都没有。于是用户问"我哪些项目需要 pull",
19
+ // 它只能去 list_files 扫盘 —— 本仓库实测扫出 37 个 git 仓库(node_modules 里的嵌套
20
+ // 仓库也算),跟界面上那 9 个最近项目完全不是一套口径。
21
+ //
22
+ // 为什么单独一个模块,不写进 src/cli/ai/tools.js:
23
+ // 那个文件被 CLI 与 Web 两端共用,是"有哪些工具"的清单;最近目录、tasks.json、
24
+ // 看板统计都只存在于 GUI 侧,塞进去等于让 CLI 反向依赖 UI。这里把数据源与格式化
25
+ // 收在一起,由 workbench/agentRoutes.js 注入进工具上下文,tools.js 只留一句转发。
26
+ // (同一手法见 taskRunner.setEnvContextProvider:只有 GUI 侧拿得到这些依赖。)
27
+ //
28
+ // 口径必须与界面对齐,所以这里**只**调用 projectRegistry 的 listProjects:
29
+ // 最近目录 ∪ 任务里的 projectPath → buildProjectEntries,
30
+ // Git 状态 → probeDirectoryGitStates,任务分列 → summarizeProjectTasks。
31
+ // 一条都不重新实现 —— 面板显示"落后 4",g ai 也必须说"落后 4"。
32
+ //
33
+ // refresh 为什么存在:领先/落后读的是本地 remote-tracking 引用,也就是"上次 fetch
34
+ // 时的快照"(见 directoryGitState.js 文件头)。不 fetch 就回答"你不需要 pull"是错的
35
+ // ——用户最初问的恰好就是这句。所以给一个 opt-in 的 refresh,复用「刷新全部」按钮
36
+ // 背后那个 fetchDirectoryRemotes(同一个进程内 in-flight 保护也一并继承)。
37
+
38
+ import { listProjects } from './projectRegistry.js';
39
+ import { readJson, TASKS_FILE } from './shared.js';
40
+ import { snapshotJobs } from './jobStore.js';
41
+ import { fetchDirectoryRemotes } from '../../utils/directoryFetch.js';
42
+
43
+ /**
44
+ * refresh=true 时逐个项目联网 fetch 的并发数。
45
+ * 与「最近项目」面板的「刷新全部」取同一档(RecentDirectoriesList.vue 的
46
+ * REFRESH_CONCURRENCY)—— 界面同一个动作是什么节奏,g ai 触发时就是什么节奏。
47
+ */
48
+ const REFRESH_CONCURRENCY = 6;
49
+
50
+ /** 单个仓库的 fetch 失败原因最多回几条:全塞进去会把这轮工具输出淹掉 */
51
+ const MAX_REPORTED_FAILURES = 5;
52
+
53
+ function gitLineOf(p) {
54
+ if (p.exists === false) return '目录不存在(可能已被删除或移动)';
55
+ const g = p.git;
56
+ if (!g) return 'Git 状态未探到(上次探测超时,重跑一次可能就有了)';
57
+ if (g.isGitRepo !== true) return '不是 Git 仓库';
58
+
59
+ const parts = [];
60
+ parts.push(g.detached ? 'HEAD 处于 detached 状态' : `分支 ${g.branch || '(未知)'}`);
61
+ if (g.hasUpstream && g.upstream) {
62
+ const track = [];
63
+ if (g.behind > 0) track.push(`落后 ${g.behind}`);
64
+ if (g.ahead > 0) track.push(`领先 ${g.ahead}`);
65
+ parts.push(`${g.upstream}${track.length ? `(${track.join(' · ')})` : '(已同步)'}`);
66
+ } else {
67
+ parts.push('未配置上游分支');
68
+ }
69
+ if (g.changed > 0) {
70
+ parts.push(`工作区 ${g.changed} 项未提交(已暂存 ${g.staged} · 未暂存 ${g.unstaged} · 未跟踪 ${g.untracked})`);
71
+ } else {
72
+ parts.push('工作区干净');
73
+ }
74
+ return parts.join(' · ');
75
+ }
76
+
77
+ function taskLineOf(p) {
78
+ const s = p.stats || {};
79
+ if (!s.total) return '任务: 无';
80
+ const parts = [`待处理 ${s.todo || 0}`, `进行中 ${s.doing || 0}`, `已完成 ${s.done || 0}`];
81
+ if (s.runningJobs > 0) parts.push(`正在执行 ${s.runningJobs}`);
82
+ if (s.errorSubtasks > 0) parts.push(`子任务报错 ${s.errorSubtasks}`);
83
+ return `任务: ${parts.join(' / ')}`;
84
+ }
85
+
86
+ /**
87
+ * 把 listProjects 的结果拼成给模型看的文本。纯函数,单测覆盖。
88
+ *
89
+ * @param {object[]} projects listProjects() 的输出
90
+ * @param {object} [opts]
91
+ * @param {boolean} [opts.refreshed] 本轮是否已联网 fetch 过
92
+ * @param {object} [opts.fetchStats] refreshProjects() 的统计(未刷新时为 null)
93
+ * @param {string} [opts.tasksFile] tasks.json 路径(给模型一条"要细节自己读"的路)
94
+ * @returns {string}
95
+ */
96
+ export function formatProjectList(projects, { refreshed = false, fetchStats = null, tasksFile = '' } = {}) {
97
+ const list = Array.isArray(projects) ? projects : [];
98
+ const lines = [];
99
+ // 抬头必须写清"这是什么口径",否则模型会以为自己看到的是一份普通目录扫描结果
100
+ lines.push(`[用户本机的「最近项目」清单 · 与 g ui 最近项目面板同一口径 · 共 ${list.length} 个]`);
101
+ lines.push(refreshed
102
+ ? '本轮已对每个项目执行 git fetch(领先/落后是 fetch 之后的真实值)。'
103
+ : '⚠️ 领先/落后读的是本地 remote-tracking 引用,即"上次 fetch 时的快照",不是实时值;'
104
+ + '用户问的是否需要 pull / 推送时,应带 refresh=true 重跑一次再下结论。');
105
+ lines.push('');
106
+
107
+ list.forEach((p, i) => {
108
+ lines.push(`${i + 1}. ${p.name || p.path}${p.isCurrent ? ' ← 当前所在项目' : ''}`);
109
+ lines.push(` 路径: ${p.path}`);
110
+ lines.push(` Git: ${gitLineOf(p)}`);
111
+ lines.push(` ${taskLineOf(p)}`);
112
+ });
113
+
114
+ if (refreshed && fetchStats) {
115
+ lines.push('');
116
+ lines.push(`本轮 fetch 结果: 成功 ${fetchStats.ok} · 跳过 ${fetchStats.skipped}(非仓库/无 remote/已有刷新在跑)· 失败 ${fetchStats.failed}`);
117
+ for (const f of fetchStats.failures.slice(0, MAX_REPORTED_FAILURES)) {
118
+ lines.push(` 失败: ${f}`);
119
+ }
120
+ if (fetchStats.failures.length > MAX_REPORTED_FAILURES) {
121
+ lines.push(` …还有 ${fetchStats.failures.length - MAX_REPORTED_FAILURES} 条失败未列出`);
122
+ }
123
+ }
124
+
125
+ if (tasksFile) {
126
+ lines.push('');
127
+ lines.push(`要任务细节(标题/描述/子任务/报错)直接读: ${tasksFile}`);
128
+ }
129
+ return lines.join('\n');
130
+ }
131
+
132
+ /**
133
+ * 逐个项目 git fetch,带回三态计数。
134
+ * 并发上限与面板一致;单个失败只记账,不中断整轮(fetchDirectoryRemotes 本身不抛)。
135
+ */
136
+ async function refreshProjects(projects) {
137
+ // 不存在的目录没有 fetch 的意义(后端也会跳过),先剔除省一轮请求
138
+ const targets = projects.filter(p => p.exists !== false).map(p => p.path);
139
+ const stats = { ok: 0, skipped: 0, failed: 0, failures: [] };
140
+ let cursor = 0;
141
+
142
+ const worker = async () => {
143
+ while (cursor < targets.length) {
144
+ const target = targets[cursor++];
145
+ const r = await fetchDirectoryRemotes(target);
146
+ if (r.status === 'ok') stats.ok += 1;
147
+ else if (r.status === 'failed') {
148
+ stats.failed += 1;
149
+ stats.failures.push(`${target}: ${r.timeout ? `fetch 超时(超过 ${r.timeoutSeconds ?? 30} 秒)` : (r.error || 'fetch 失败')}`);
150
+ } else {
151
+ stats.skipped += 1;
152
+ }
153
+ }
154
+ };
155
+
156
+ await Promise.all(
157
+ Array.from({ length: Math.max(1, Math.min(REFRESH_CONCURRENCY, targets.length)) }, worker),
158
+ );
159
+ return stats;
160
+ }
161
+
162
+ /**
163
+ * 生成注入给工具上下文的 listProjects 实现。
164
+ *
165
+ * @param {object} deps
166
+ * @param {object} deps.configManager 读最近目录(与看板同一份配置)
167
+ * @param {Function} [deps.getCurrentProjectPath] 当前项目路径,用于打"← 当前"
168
+ * @returns {(args?: { refresh?: boolean }) => Promise<string>}
169
+ */
170
+ export function createProjectListProvider({ configManager, getCurrentProjectPath } = {}) {
171
+ return async function listProjectsForAgent({ refresh = false } = {}) {
172
+ let recentDirs = [];
173
+ try {
174
+ recentDirs = (await configManager.getRecentDirectories()) || [];
175
+ } catch {
176
+ // 与看板同一退路:读不到最近目录就只按"任务里出现过的项目"列
177
+ }
178
+ const { tasks } = await readJson(TASKS_FILE, { tasks: [] });
179
+ const jobs = snapshotJobs();
180
+ const taskList = Array.isArray(tasks) ? tasks : [];
181
+ const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
182
+
183
+ const probeOptions = refresh ? { useCache: false } : undefined;
184
+ let projects = await listProjects({ recentDirs, tasks: taskList, jobs, currentProjectPath, probeOptions });
185
+
186
+ let fetchStats = null;
187
+ if (refresh && projects.length > 0) {
188
+ fetchStats = await refreshProjects(projects);
189
+ // fetch 动了 remote-tracking 引用 → 重探一次。必须 useCache:false:
190
+ // 否则拿到的是 15s TTL 里 fetch 之前那份快照,等于白刷(与 fs.js 的 fetch 路由同因)。
191
+ projects = await listProjects({
192
+ recentDirs, tasks: taskList, jobs, currentProjectPath, probeOptions: { useCache: false },
193
+ });
194
+ }
195
+
196
+ return formatProjectList(projects, { refreshed: refresh, fetchStats, tasksFile: TASKS_FILE });
197
+ };
198
+ }
@@ -0,0 +1,152 @@
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
+ // list_projects 工具输出(projectTool.js formatProjectList)纯函数单测。
16
+ //
17
+ // 这里守的是三件事:
18
+ // 1. 模型**真的**能读到项目名/路径/Git 状态/任务进度 —— 用户最初的问题
19
+ // ("我哪些项目需要 pull")就靠这几个字段回答,少一个都答不了;
20
+ // 2. 领先/落后在没刷新时必须被明确标注为"本地快照",并且要提示 refresh=true
21
+ // —— 否则模型会把"上次 fetch 时的状态"当成实时值,给出相反的结论;
22
+ // 3. 各种边界(非仓库 / 目录不存在 / 探测超时 / 无 upstream / detached / 无任务)
23
+ // 都有可读的说法,而不是抛出 undefined。
24
+
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+
28
+ import { formatProjectList } from './projectTool.js';
29
+
30
+ /** 造一条 listProjects() 口径的项目 */
31
+ function project(overrides = {}) {
32
+ const baseGit = {
33
+ isGitRepo: true,
34
+ branch: 'main',
35
+ upstream: 'origin/main',
36
+ hasUpstream: true,
37
+ detached: false,
38
+ ahead: 0,
39
+ behind: 0,
40
+ changed: 0,
41
+ staged: 0,
42
+ unstaged: 0,
43
+ untracked: 0,
44
+ };
45
+ const baseStats = { total: 0, todo: 0, doing: 0, done: 0, progress: 0, runningJobs: 0, errorSubtasks: 0 };
46
+ const merged = {
47
+ path: 'D:\\ws\\proj',
48
+ key: 'd:\\ws\\proj',
49
+ name: 'proj',
50
+ source: 'recent',
51
+ isCurrent: false,
52
+ exists: true,
53
+ git: baseGit,
54
+ stats: baseStats,
55
+ ...overrides,
56
+ };
57
+ // git / stats 是"部分覆盖"语义,但 git: null 表示"探测超时、没探到",不能被默认值吃掉
58
+ if ('git' in overrides) merged.git = overrides.git === null ? null : { ...baseGit, ...overrides.git };
59
+ if ('stats' in overrides) merged.stats = { ...baseStats, ...overrides.stats };
60
+ return merged;
61
+ }
62
+
63
+ test('未刷新时明确标注"本地快照"并提示 refresh=true', () => {
64
+ const text = formatProjectList([project()]);
65
+ assert.match(text, /共 1 个/);
66
+ assert.match(text, /上次 fetch 时的快照/);
67
+ assert.match(text, /refresh=true/);
68
+ });
69
+
70
+ test('刷新过之后不再出现快照警告,改为报出三态计数', () => {
71
+ const text = formatProjectList([project()], {
72
+ refreshed: true,
73
+ fetchStats: { ok: 2, skipped: 1, failed: 1, failures: ['D:\\ws\\bad: fetch 失败'] },
74
+ });
75
+ assert.doesNotMatch(text, /上次 fetch 时的快照/);
76
+ assert.match(text, /成功 2 · 跳过 1.*失败 1/);
77
+ assert.match(text, /D:\\ws\\bad: fetch 失败/);
78
+ });
79
+
80
+ test('落后/领先/未提交都出现在 Git 行里,且当前项目被标出', () => {
81
+ const text = formatProjectList([
82
+ project({ isCurrent: true, git: { branch: 'main', behind: 4, ahead: 2, changed: 3, staged: 1, unstaged: 1, untracked: 1 } }),
83
+ ]);
84
+ assert.match(text, /← 当前所在项目/);
85
+ assert.match(text, /分支 main/);
86
+ assert.match(text, /origin\/main(落后 4 · 领先 2)/);
87
+ assert.match(text, /工作区 3 项未提交(已暂存 1 · 未暂存 1 · 未跟踪 1)/);
88
+ });
89
+
90
+ test('已同步 / 无 upstream / detached 各有说法,不出现 undefined', () => {
91
+ const synced = formatProjectList([project({ git: { ahead: 0, behind: 0 } })]);
92
+ assert.match(synced, /origin\/main(已同步)/);
93
+
94
+ const noUpstream = formatProjectList([project({ git: { upstream: null, hasUpstream: false } })]);
95
+ assert.match(noUpstream, /未配置上游分支/);
96
+
97
+ const detached = formatProjectList([project({ git: { detached: true, branch: null } })]);
98
+ assert.match(detached, /detached/);
99
+ assert.doesNotMatch(detached, /undefined/);
100
+ });
101
+
102
+ test('非仓库 / 目录不存在 / 探测超时都能区分,不谎报成"干净"', () => {
103
+ const notRepo = formatProjectList([project({ git: { isGitRepo: false } })]);
104
+ assert.match(notRepo, /不是 Git 仓库/);
105
+ assert.doesNotMatch(notRepo, /工作区干净/);
106
+
107
+ const missing = formatProjectList([project({ exists: false })]);
108
+ assert.match(missing, /目录不存在/);
109
+
110
+ const unknown = formatProjectList([project({ git: null })]);
111
+ assert.match(unknown, /Git 状态未探到/);
112
+ });
113
+
114
+ test('任务进度按看板三列报出,无任务时说"无"', () => {
115
+ const withTasks = formatProjectList([project({ stats: { total: 9, todo: 1, doing: 2, done: 6, runningJobs: 1, errorSubtasks: 1 } })]);
116
+ assert.match(withTasks, /待处理 1 \/ 进行中 2 \/ 已完成 6/);
117
+ assert.match(withTasks, /正在执行 1/);
118
+ assert.match(withTasks, /子任务报错 1/);
119
+
120
+ assert.match(formatProjectList([project()]), /任务: 无/);
121
+ });
122
+
123
+ test('多个项目按顺序编号,条目之间不串行', () => {
124
+ const text = formatProjectList([
125
+ project({ path: 'D:\\ws\\a', name: 'a' }),
126
+ project({ path: 'D:\\ws\\b', name: 'b' }),
127
+ ]);
128
+ assert.match(text, /1\. a/);
129
+ assert.match(text, /2\. b/);
130
+ assert.equal(text.match(/路径: /g).length, 2);
131
+ });
132
+
133
+ test('失败条目过多时截断,但保留总数', () => {
134
+ const failures = Array.from({ length: 8 }, (_, i) => `D:\\ws\\p${i}: fetch 失败`);
135
+ const text = formatProjectList([project()], {
136
+ refreshed: true,
137
+ fetchStats: { ok: 0, skipped: 0, failed: 8, failures },
138
+ });
139
+ assert.match(text, /还有 3 条失败未列出/);
140
+ assert.doesNotMatch(text, /D:\\ws\\p7/);
141
+ });
142
+
143
+ test('给了 tasks.json 路径时把它带给模型(要细节自己读)', () => {
144
+ const text = formatProjectList([project()], { tasksFile: 'C:\\Users\\u\\.zen-gitsync\\tasks.json' });
145
+ assert.match(text, /C:\\Users\\u\\\.zen-gitsync\\tasks\.json/);
146
+ });
147
+
148
+ test('空清单也是一个合法回答(不抛异常、不说"共 undefined 个")', () => {
149
+ const text = formatProjectList([]);
150
+ assert.match(text, /共 0 个/);
151
+ assert.doesNotMatch(text, /undefined/);
152
+ });