zen-gitsync 2.13.14 → 2.13.16

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 (28) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -3
  3. package/scripts/README_COLOR_CONVERTER.md +196 -196
  4. package/scripts/README_FONTSIZE_CONVERTER.md +278 -278
  5. package/scripts/README_SPACING_CONVERTER.md +126 -126
  6. package/scripts/README_STYLE_VARS.md +180 -180
  7. package/src/ui/public/assets/{EditorView-S6HDGyLD.js → EditorView-CRpjzBS1.js} +0 -0
  8. package/src/ui/public/assets/{SourceMapView-C10WxgGh.js → SourceMapView-ByHkwMwM.js} +1 -1
  9. package/src/ui/public/assets/WorkbenchView-Dhle7BQe.css +1 -0
  10. package/src/ui/public/assets/WorkbenchView-cBXTpeWC.js +9 -0
  11. package/src/ui/public/assets/{_plugin-vue_export-helper-8__zdPsB.js → _plugin-vue_export-helper-CT67BnrS.js} +2 -2
  12. package/src/ui/public/assets/{index-D-XpqSmG.js → index-C19XAWFW.js} +15 -14
  13. package/src/ui/public/assets/index-C3VghkAQ.css +1 -0
  14. package/src/ui/public/favicon.svg +75 -75
  15. package/src/ui/public/index.html +20 -20
  16. package/src/ui/public/logo.svg +74 -74
  17. package/src/ui/server/index.js +2 -1
  18. package/src/ui/server/routes/exec.js +10 -9
  19. package/src/ui/server/routes/status.js +16 -17
  20. package/src/ui/server/routes/terminal.js +12 -13
  21. package/src/ui/server/routes/workbench.js +433 -33
  22. package/src/ui/server/utils/shellQuote.js +68 -0
  23. package/src/ui/server/utils/shellQuote.test.js +110 -0
  24. package/src/utils/index.js +60 -29
  25. package/src/utils/parseCwdArg.test.js +86 -0
  26. package/src/ui/public/assets/WorkbenchView-7COIW5s0.js +0 -9
  27. package/src/ui/public/assets/WorkbenchView-zVvptY6a.css +0 -1
  28. package/src/ui/public/assets/index-CHX2_Fog.css +0 -1
@@ -83,11 +83,12 @@ const MAX_ATTACHMENTS_PER_SUBTASK = 9;
83
83
  const DEFAULT_SUBTASK_INSTRUCTION_ZH = `你是一名任务拆分助手。
84
84
 
85
85
  【思考过程】
86
- 在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段简短分析,再输出 JSON):
87
- - 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息?
88
- - 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件?
86
+ 在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段较长的仔细分析,再输出 JSON)。分析要覆盖足够维度,不要简短一两句话就过:
87
+ - 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息和隐藏诉求?
88
+ - 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件、风险点?
89
89
  - 自然的执行顺序是什么?哪些步骤是前置依赖?哪些可以并行?
90
- - 哪些步骤可能失败、需要单独验证?
90
+ - 哪些步骤可能失败、需要单独验证?验证方式是什么?
91
+ - 是否需要先做调研/读现有代码的步骤,再做修改?
91
92
 
92
93
  【拆分原则】
93
94
  1. 单一职责:每个子任务只做一件事,避免"做 A 和 B"。
@@ -111,11 +112,12 @@ JSON 要用 \`\`\`json ... \`\`\` 代码块包裹,前面可以有分析文字
111
112
  const DEFAULT_SUBTASK_INSTRUCTION_EN = `You are a task breakdown assistant.
112
113
 
113
114
  [Thinking process]
114
- Before producing JSON, think carefully (put your thoughts in reasoning if the model supports it; otherwise output a short analysis first, then JSON):
115
- - What is the real goal? What key information is in the description / images / context?
116
- - Which stack, modules, files, constraints are involved? Any easily missed edge cases?
115
+ Before producing JSON, think carefully (put your thoughts in reasoning if the model supports it; otherwise output a longer careful analysis first, then JSON). Cover enough dimensions — don't settle for a one-or-two-sentence summary:
116
+ - What is the real goal? What key info and hidden needs are in the description / images / context?
117
+ - Which stack, modules, files, constraints are involved? Any easily missed edge cases or risks?
117
118
  - What is the natural execution order? What blocks what? What can run in parallel?
118
- - Which steps may fail and need separate verification?
119
+ - Which steps may fail and need separate verification? How will you verify?
120
+ - Should a research / read-existing-code step come before the changes?
119
121
 
120
122
  [Breakdown principles]
121
123
  1. Single responsibility: each subtask does only one thing, avoid bundling "do A and B".
@@ -998,7 +1000,9 @@ function snapshotJobs() {
998
1000
  startedAt: j.startedAt || null,
999
1001
  endedAt: j.endedAt || null,
1000
1002
  exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
1001
- error: j.error || null
1003
+ error: j.error || null,
1004
+ // 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
1005
+ claudeSessionId: j.claudeSessionId || null
1002
1006
  }));
1003
1007
  }
1004
1008
 
@@ -1006,16 +1010,21 @@ function snapshotJobs() {
1006
1010
  // 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
1007
1011
  // 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
1008
1012
  // 到前端面板展示。
1009
- function launchClaudeInNewWindow(cwd, promptText) {
1013
+ function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
1010
1014
  return new Promise((resolve, reject) => {
1011
- const args = [
1015
+ const args = [];
1016
+ // 续接历史会话:--resume 必须放在 -p 之前,确保 claude CLI 先识别 resume 上下文
1017
+ if (resumeSessionId) {
1018
+ args.push('--resume', String(resumeSessionId));
1019
+ }
1020
+ args.push(
1012
1021
  '-p', promptText,
1013
1022
  '--input-format', 'text',
1014
1023
  '--output-format', 'stream-json',
1015
1024
  '--verbose',
1016
1025
  '--permission-mode', 'bypassPermissions',
1017
1026
  '--dangerously-skip-permissions'
1018
- ];
1027
+ );
1019
1028
  let child;
1020
1029
  let spawnedExe = 'claude';
1021
1030
  if (process.platform === 'win32') {
@@ -1061,20 +1070,35 @@ function launchClaudeInNewWindow(cwd, promptText) {
1061
1070
  });
1062
1071
  }
1063
1072
 
1064
- // 顺序执行一个任务下所有子任务;上一个结束再启动下一个
1065
- async function runTaskQueue(task, repoPath, branch) {
1066
- // 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
1067
- // 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
1068
- // 故意不用 raw output 全文——LLM 已经习惯"摘要 + 关键结论"的格式,且不会
1073
+ /**
1074
+ * 顺序执行一个任务下所有子任务;上一个结束再启动下一个。
1075
+ * 入参 fromIndex 指定从哪个 sub 开始(0-based,默认 0),用于"从此处开始"入口;
1076
+ * fromIndex>0 时,把 [0, fromIndex) 区间内已 done 的 sub 输出预填到 priorOutputs,
1077
+ * 这样后续 sub 仍能拿到前序上下文(跟单 sub 执行的语义保持一致)。
1078
+ */
1079
+ async function runTaskQueue(task, repoPath, branch, opts) {
1080
+ // 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
1081
+ // 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
1082
+ // 故意不用 raw output 全文——LLM 已经习惯"摘要 + 关键结论"的格式,且不会
1069
1083
  // 一次塞几 MB 进 prompt 烧 token。truncate 到每条 MAX_PREV_OUTPUT_CHARS。
1070
1084
  const MAX_PREV_OUTPUT_CHARS = 2000
1071
- const priorOutputs = []
1072
- for (const sub of task.subtasks) {
1085
+ const requested = Number(opts && opts.fromIndex)
1086
+ const fromIndex = Number.isInteger(requested) && requested >= 0 && requested < task.subtasks.length
1087
+ ? requested
1088
+ : 0
1089
+ // 从 fromIndex 开始时,把前面已 done 的 sub 输出预填进 priorOutputs,
1090
+ // 否则"从中间开始"会丢失前序上下文。
1091
+ const priorOutputs = fromIndex > 0
1092
+ ? await collectPriorOutputsUpTo(task, fromIndex)
1093
+ : []
1094
+ for (let i = fromIndex; i < task.subtasks.length; i++) {
1095
+ const sub = task.subtasks[i]
1073
1096
  if (sub.status === 'done') continue;
1074
1097
  await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
1098
+ // 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
1099
+ // sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
1100
+ await persistTaskAfterRun(task)
1075
1101
  }
1076
- // 写回 tasks.json
1077
- await persistTaskAfterRun(task)
1078
1102
  }
1079
1103
 
1080
1104
  /**
@@ -1088,8 +1112,15 @@ async function runTaskQueue(task, repoPath, branch) {
1088
1112
  * 前序 done 子任务的输出摘要(in-place 追加)。用于把同一任务下
1089
1113
  * 前面已完成的 sub 产物拼到当前 sub 的 prompt 头部,让 Claude
1090
1114
  * 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done 的 sub"。
1115
+ * @param {object} [options]
1116
+ * @param {string|null} [options.resumeSessionId]
1117
+ * 续接历史 claude 会话:传入上一轮通过 stream-json 的 system.init
1118
+ * 事件捕获到的 session_id,本轮以 --resume <id> 启动,claude 会带着
1119
+ * 上下文继续对话。前端"简单任务执行完成后继续聊"走的就是这条路径。
1091
1120
  */
1092
- async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs) {
1121
+ async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs, options) {
1122
+ const opts = options || {}
1123
+ const resumeSessionId = opts.resumeSessionId || null
1093
1124
  const MAX_PREV_OUTPUT_CHARS = 2000
1094
1125
  const promptTemplate = sub.promptOverride || (task.promptId
1095
1126
  ? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
@@ -1124,10 +1155,18 @@ ${prompt}`
1124
1155
  // claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
1125
1156
  // 识别为附件(图片 / PDF / 文本均可)。
1126
1157
  // 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
1127
- const allAttachments = [
1128
- ...(Array.isArray(task.attachments) ? task.attachments : []),
1129
- ...(Array.isArray(sub.attachments) ? sub.attachments : [])
1130
- ];
1158
+ // 注意:run-simple 路径下 virtualSub.attachments 就是 task.attachments 的同一引用,
1159
+ // 不去重会把同一张图在 prompt 里列两遍。按 absolutePath 去重。
1160
+ const taskAtts = Array.isArray(task.attachments) ? task.attachments : [];
1161
+ const subAtts = Array.isArray(sub.attachments) ? sub.attachments : [];
1162
+ const seen = new Set();
1163
+ const allAttachments = [];
1164
+ for (const a of [...subAtts, ...taskAtts]) {
1165
+ if (!a || !a.absolutePath) continue;
1166
+ if (seen.has(a.absolutePath)) continue;
1167
+ seen.add(a.absolutePath);
1168
+ allAttachments.push(a);
1169
+ }
1131
1170
  if (allAttachments.length > 0) {
1132
1171
  const lines = allAttachments
1133
1172
  .filter(a => a && a.absolutePath)
@@ -1144,7 +1183,9 @@ ${prompt}`
1144
1183
  subId: sub.id,
1145
1184
  title: `${task.title} / ${sub.title}`,
1146
1185
  status: 'pending',
1147
- prompt
1186
+ prompt,
1187
+ // 续接历史会话时先填上,首条 system.init 事件回来后再用真值覆盖(通常等同)
1188
+ claudeSessionId: resumeSessionId
1148
1189
  };
1149
1190
  jobs.set(jobId, job);
1150
1191
  sub.status = 'running';
@@ -1152,7 +1193,7 @@ ${prompt}`
1152
1193
  publish('job:update', job);
1153
1194
 
1154
1195
  try {
1155
- const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
1196
+ const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt, resumeSessionId);
1156
1197
  job.pid = pid;
1157
1198
  // 保存 child 引用,供 cancel 接口调用 kill
1158
1199
  job.child = child;
@@ -1194,6 +1235,15 @@ ${prompt}`
1194
1235
  }
1195
1236
  let evt;
1196
1237
  try { evt = JSON.parse(trimmed) } catch { continue }
1238
+ // 捕获 system.init 里的 session_id,供后续"续接对话"用(--resume <id>)。
1239
+ // 一个 claude -p 调用只在首行发一次,所以仅在 job 还没记到时写入。
1240
+ if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
1241
+ if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
1242
+ job.claudeSessionId = evt.session_id;
1243
+ publish('job:update', job);
1244
+ }
1245
+ continue;
1246
+ }
1197
1247
  if (evt.type !== 'assistant') continue;
1198
1248
  const blocks = evt.message?.content;
1199
1249
  if (!Array.isArray(blocks)) continue;
@@ -1289,11 +1339,19 @@ async function persistTaskAfterRun(task) {
1289
1339
  * 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
1290
1340
  */
1291
1341
  async function collectPriorOutputs(task, targetSub) {
1342
+ const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
1343
+ if (targetIdx < 0) return []
1344
+ return collectPriorOutputsUpTo(task, targetIdx)
1345
+ }
1346
+
1347
+ /**
1348
+ * 收集 [0, endIdx) 区间内已 done 的 sub 输出摘要,作为队列内 sub 的前序上下文。
1349
+ * runTaskQueue 在 fromIndex>0 时调这个,让"从此处开始"也能拼上前序 done sub 的结论。
1350
+ */
1351
+ async function collectPriorOutputsUpTo(task, endIdx) {
1292
1352
  const MAX_PREV_OUTPUT_CHARS = 2000
1293
1353
  const prior = []
1294
- const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
1295
- if (targetIdx < 0) return prior
1296
- for (let i = 0; i < targetIdx; i++) {
1354
+ for (let i = 0; i < endIdx; i++) {
1297
1355
  const s = task.subtasks[i]
1298
1356
  if (s.status !== 'done') continue
1299
1357
  // 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
@@ -1590,6 +1648,11 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1590
1648
  const desc = String(req.body?.desc || '').trim();
1591
1649
  const taskId = String(req.body?.taskId || '').trim();
1592
1650
  const promptId = String(req.body?.promptId || '').trim();
1651
+ // customUserBlock:用户在前端 prompt 预览页编辑过的 user prompt。非空时
1652
+ // 直接拿它发给 LLM,跳过服务端拼装;为空时按老逻辑现拼。
1653
+ // 注意:即便传了 customUserBlock,attachments/imageDataUrls 仍按 taskId 重新读,
1654
+ // 因为图片要随消息发,不能跟 prompt 文本一起塞。
1655
+ const customUserBlock = typeof req.body?.customUserBlock === 'string' ? req.body.customUserBlock : '';
1593
1656
  if (!title) {
1594
1657
  return res.status(400).json({ success: false, error: '任务标题不能为空' });
1595
1658
  }
@@ -1687,7 +1750,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1687
1750
  } catch { /* 没拿到附件不影响拆分 */ }
1688
1751
  }
1689
1752
 
1690
- const userBlock = `${userInstruction}
1753
+ const userBlock = customUserBlock.trim() ? customUserBlock : `${userInstruction}
1691
1754
 
1692
1755
  ---
1693
1756
 
@@ -1700,7 +1763,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1700
1763
  - 项目根目录:${projectPath || '(未指定)'}
1701
1764
  - 主要 manifest:${manifestHint || '(未识别到)'}
1702
1765
 
1703
- 请先简要分析(可以放在 reasoning 中或直接写出来),然后给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
1766
+ 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
1704
1767
  {
1705
1768
  "subtasks": [
1706
1769
  { "title": "子任务标题", "desc": "具体描述" }
@@ -1753,6 +1816,103 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1753
1816
  }
1754
1817
  });
1755
1818
 
1819
+ // POST /api/workbench/tasks/ai-split-preview
1820
+ // body: { title, desc?, taskId?, promptId? }
1821
+ // → { success, system, user, hasImages, imageCount }
1822
+ // "AI 拆分前预览/编辑 prompt" 用:跟 /ai-split-subtasks 做完全一样的拼装,
1823
+ // 但**不调 LLM**,只把拼好的 system + user 返回。前端在 UI 上 textarea 展示,
1824
+ // 用户改完按"发送给 AI"再正式调 /ai-split-subtasks 并把改过的文本作为
1825
+ // customUserBlock 传回。这样省一次 LLM 调用,且让用户能修正提示词后再拆分。
1826
+ app.post('/api/workbench/tasks/ai-split-preview', async (req, res) => {
1827
+ try {
1828
+ const title = String(req.body?.title || '').trim();
1829
+ const desc = String(req.body?.desc || '').trim();
1830
+ const taskId = String(req.body?.taskId || '').trim();
1831
+ const promptId = String(req.body?.promptId || '').trim();
1832
+ if (!title) {
1833
+ return res.status(400).json({ success: false, error: '任务标题不能为空' });
1834
+ }
1835
+
1836
+ const userInstruction = await readSubtaskInstruction(req);
1837
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1838
+ const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
1839
+ const manifestHint = await detectProjectManifest(projectPath);
1840
+
1841
+ let templateBlock = '';
1842
+ if (promptId) {
1843
+ try {
1844
+ const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
1845
+ const p = (promptData.prompts || []).find(x => x.id === promptId);
1846
+ if (p && p.content) {
1847
+ templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
1848
+ }
1849
+ } catch { /* 模板读取失败不影响预览 */ }
1850
+ }
1851
+
1852
+ // 附件:只列条目,不读图片二进制(preview 阶段不需要 image_data_url)
1853
+ let attachmentBlock = '';
1854
+ let imageCount = 0;
1855
+ if (taskId) {
1856
+ try {
1857
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1858
+ const task = (data.tasks || []).find(t => t.id === taskId);
1859
+ const atts = Array.isArray(task?.attachments) ? task.attachments : [];
1860
+ if (atts.length > 0) {
1861
+ const lines = [];
1862
+ for (let i = 0; i < atts.length; i++) {
1863
+ const a = atts[i];
1864
+ if (!a || !a.absolutePath) continue;
1865
+ lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
1866
+ if (isImageExt(a.ext)) imageCount++;
1867
+ }
1868
+ if (lines.length > 0) {
1869
+ const imgNote = imageCount > 0
1870
+ ? `(其中 ${imageCount} 张图片已随消息一并发送,请直接基于图片内容拆分)`
1871
+ : '';
1872
+ attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
1873
+ }
1874
+ }
1875
+ } catch { /* 没拿到附件不影响预览 */ }
1876
+ }
1877
+
1878
+ const userBlock = `${userInstruction}
1879
+
1880
+ ---
1881
+
1882
+ ## 待拆分的任务
1883
+ 标题:${title}
1884
+ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
1885
+
1886
+ ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
1887
+ - 项目名称:${projectName}
1888
+ - 项目根目录:${projectPath || '(未指定)'}
1889
+ - 主要 manifest:${manifestHint || '(未识别到)'}
1890
+
1891
+ 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
1892
+ {
1893
+ "subtasks": [
1894
+ { "title": "子任务标题", "desc": "具体描述" }
1895
+ ]
1896
+ }
1897
+
1898
+ **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
1899
+ 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
1900
+ 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
1901
+ 3. JSON 中不允许写注释。
1902
+ 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
1903
+
1904
+ res.json({
1905
+ success: true,
1906
+ system: userInstruction,
1907
+ user: userBlock,
1908
+ hasImages: imageCount > 0,
1909
+ imageCount
1910
+ });
1911
+ } catch (err) {
1912
+ res.status(500).json({ success: false, error: '生成预览失败: ' + (err?.message || String(err)) });
1913
+ }
1914
+ });
1915
+
1756
1916
  // POST /api/workbench/tasks/parse-subtasks
1757
1917
  // body: { raw: string }
1758
1918
  // → { success, subtasks, parseError, parseStage }
@@ -2168,6 +2328,40 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2168
2328
  }
2169
2329
  });
2170
2330
 
2331
+ // ── 从指定子任务开始执行 ───────────────────────────────────────────
2332
+ // POST /api/workbench/tasks/:id/run-from
2333
+ // 入参 body: { startSubIndex: number }
2334
+ // 行为:
2335
+ // - 从 task.subtasks[startSubIndex] 开始按序执行,前面 [0, startSubIndex) 区间的
2336
+ // done sub 输出会自动作为前序上下文(同单 sub 执行的语义)
2337
+ // - 前面 error / running 的 sub 不会被自动重跑;从 startSubIndex 开始重新走队列
2338
+ // - 适合"中间某个 sub 失败/被取消后,从该 sub 继续"的场景
2339
+ // - startSubIndex 必须落在 [0, task.subtasks.length) 范围内
2340
+ app.post('/api/workbench/tasks/:id/run-from', async (req, res) => {
2341
+ try {
2342
+ const data = await readJson(TASKS_FILE, { tasks: [] });
2343
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
2344
+ if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
2345
+ if (!task.subtasks || task.subtasks.length === 0) {
2346
+ return res.status(400).json({ success: false, error: '任务没有子任务' });
2347
+ }
2348
+ const startSubIndex = Number(req.body?.startSubIndex);
2349
+ if (!Number.isInteger(startSubIndex)
2350
+ || startSubIndex < 0
2351
+ || startSubIndex >= task.subtasks.length) {
2352
+ return res.status(400).json({ success: false, error: 'startSubIndex 越界' });
2353
+ }
2354
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
2355
+ // 异步执行,立即返回(同 /run 的 fire-and-forget 模式)
2356
+ res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
2357
+ runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
2358
+ publish('task:error', { taskId: task.id, error: err.message });
2359
+ });
2360
+ } catch (err) {
2361
+ res.status(500).json({ success: false, error: err.message });
2362
+ }
2363
+ });
2364
+
2171
2365
  // ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
2172
2366
  // POST /api/workbench/tasks/:id/run-simple
2173
2367
  // 行为:
@@ -2303,6 +2497,74 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2303
2497
  }
2304
2498
  });
2305
2499
 
2500
+ // ── 续接简单任务对话 ────────────────────────────────────────────
2501
+ // POST /api/workbench/jobs/:id/continue
2502
+ // 入参 body: { userMessage: string }
2503
+ // 行为:
2504
+ // - 仅用于 type==='simple' 的任务,基于上一轮 job 捕获到的 claudeSessionId,
2505
+ // 用 claude --resume <id> 续接历史对话,**新一轮 = 新 job**
2506
+ // - 新 job 的 subId 形如 `${task.id}__simple__r${n}`,跟首轮 `${task.id}__simple`
2507
+ // 区分;前端按 subId 前缀聚合渲染对话流
2508
+ // - 上一轮还在 pending/running 时拒绝(避免 session 并发跑乱)
2509
+ // - 上一轮未捕获到 session_id(claude 在 init 前就 crash)时拒绝
2510
+ app.post('/api/workbench/jobs/:id/continue', async (req, res) => {
2511
+ try {
2512
+ const userMessage = String(req.body?.userMessage || '').trim();
2513
+ if (!userMessage) {
2514
+ return res.status(400).json({ success: false, error: 'userMessage 不能为空' });
2515
+ }
2516
+ const prevJob = jobs.get(req.params.id);
2517
+ if (!prevJob) {
2518
+ return res.status(404).json({ success: false, error: 'job 不存在' });
2519
+ }
2520
+ if (prevJob.status === 'running' || prevJob.status === 'pending') {
2521
+ return res.status(400).json({ success: false, error: '上一轮还在执行,请等结束后再续接' });
2522
+ }
2523
+ if (!prevJob.claudeSessionId) {
2524
+ return res.status(400).json({ success: false, error: '无法续接:会话标识未捕获' });
2525
+ }
2526
+ const data = await readJson(TASKS_FILE, { tasks: [] });
2527
+ const task = (data.tasks || []).find(t => t.id === prevJob.taskId);
2528
+ if (!task) {
2529
+ return res.status(404).json({ success: false, error: '所属任务不存在' });
2530
+ }
2531
+ if (task.type !== 'simple') {
2532
+ return res.status(400).json({ success: false, error: '仅支持简单任务的续接' });
2533
+ }
2534
+ // 计算续接轮次号:扫所有同 task 下的 __simple / __simple__rN job,取最大 N + 1
2535
+ const subIdPrefix = `${task.id}__simple`;
2536
+ let maxRound = 0;
2537
+ for (const j of jobs.values()) {
2538
+ if (j.taskId !== task.id) continue;
2539
+ if (j.subId === subIdPrefix) continue;
2540
+ const m = j.subId && j.subId.match(/__simple__r(\d+)$/);
2541
+ if (m) {
2542
+ const n = parseInt(m[1], 10);
2543
+ if (!isNaN(n) && n > maxRound) maxRound = n;
2544
+ }
2545
+ }
2546
+ const nextRound = maxRound + 1;
2547
+ const virtualSub = {
2548
+ id: `${task.id}__simple__r${nextRound}`,
2549
+ title: `续接 #${nextRound}`,
2550
+ desc: '',
2551
+ status: 'todo',
2552
+ // 用户输入的续接消息直接作为本轮 prompt(走 promptOverride,不再拼模板)
2553
+ promptOverride: userMessage,
2554
+ // 续接轮不带附件,避免重复推附件给 claude(已在首轮 prompt 里)
2555
+ attachments: []
2556
+ };
2557
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
2558
+ res.json({ success: true, message: '已加入续接队列' });
2559
+ runSingleSubtask(task, virtualSub, repoPath, '', [], { resumeSessionId: prevJob.claudeSessionId })
2560
+ .catch(err => {
2561
+ publish('task:error', { taskId: task.id, error: err.message });
2562
+ });
2563
+ } catch (err) {
2564
+ res.status(500).json({ success: false, error: err.message });
2565
+ }
2566
+ });
2567
+
2306
2568
  // ── 执行日志管理 API(持久化 + 清理) ────────────────────────────
2307
2569
  // 路径都在 /api/workbench/jobs/* 下;/list、/config、/batch-delete、/clear
2308
2570
  // 是字面路径,必须排在 :id 路由之前注册,避免被 :id 匹配吞掉。
@@ -2467,6 +2729,144 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2467
2729
  }
2468
2730
  })
2469
2731
 
2732
+ // POST /api/workbench/tasks/:id/clear-execution
2733
+ // 一次性清空指定 task 的所有执行痕迹:
2734
+ // 1. 删除内存 + 磁盘 jobs.json 中该 task 的全部 job(跳过 running/pending)
2735
+ // 2. 把 tasks.json 中该 task 的所有 subtasks status 重置为 'todo'(让"重新执行"能从头跑)
2736
+ // 3. 同步清掉 sub 的 error 字段,broadcast task:update + 一组 sub:update
2737
+ // 任务描述、附件、提示词模板等"内容"字段保留不动——只清"执行痕迹"。
2738
+ // 活跃态(有 running/pending job)时拒绝,避免误杀正在跑的实例。
2739
+ app.post('/api/workbench/tasks/:id/clear-execution', async (req, res) => {
2740
+ try {
2741
+ const taskId = req.params.id
2742
+ if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
2743
+ // 检查活跃 job
2744
+ const live = []
2745
+ for (const j of jobs.values()) {
2746
+ if (j.taskId !== taskId) continue
2747
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id)
2748
+ }
2749
+ if (live.length > 0) {
2750
+ return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
2751
+ }
2752
+ // 1) 清空 jobs(内存 + 磁盘)
2753
+ const removedJobIds = []
2754
+ for (const j of jobs.values()) {
2755
+ if (j.taskId !== taskId) continue
2756
+ jobs.delete(j.id)
2757
+ removedJobIds.push(j.id)
2758
+ }
2759
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
2760
+ if (jobsData && Array.isArray(jobsData.jobs)) {
2761
+ const before = jobsData.jobs.length
2762
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
2763
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
2764
+ }
2765
+ // 2) 重置 subtasks.status → todo
2766
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] })
2767
+ const task = (tasksData.tasks || []).find(t => t.id === taskId)
2768
+ if (!task) {
2769
+ return res.json({ success: true, removedJobs: removedJobIds.length, resetSubs: 0, message: '任务不存在,仅清空 job' })
2770
+ }
2771
+ let resetSubs = 0
2772
+ if (Array.isArray(task.subtasks)) {
2773
+ for (const s of task.subtasks) {
2774
+ if (s.status !== 'todo') {
2775
+ s.status = 'todo'
2776
+ resetSubs++
2777
+ if (s.error) delete s.error
2778
+ publish('sub:update', { taskId, sub: s })
2779
+ }
2780
+ }
2781
+ task.updatedAt = nowIso()
2782
+ await writeJson(TASKS_FILE, tasksData)
2783
+ publish('task:update', task)
2784
+ }
2785
+ res.json({
2786
+ success: true,
2787
+ removedJobs: removedJobIds.length,
2788
+ resetSubs,
2789
+ message: `已清空 ${removedJobIds.length} 条执行记录,${resetSubs} 个子任务重置为待执行`
2790
+ })
2791
+ } catch (err) {
2792
+ res.status(500).json({ success: false, error: '清空执行痕迹失败: ' + (err.message || String(err)) })
2793
+ }
2794
+ })
2795
+
2796
+ // POST /api/workbench/tasks/:id/reset-shell
2797
+ // 把 task 还原成"只有标题的空壳",清掉 desc / attachments / promptId / subtasks。
2798
+ // 与 clear-execution 的区别:
2799
+ // - clear-execution 只重置 sub.status + 清 jobs(保留拆分/描述/附件)
2800
+ // - reset-shell 直接删除 subtasks 数组和描述/附件,任务只剩标题
2801
+ // 同时清空该 task 的所有 job(沿用 by-task 删除语义,跳过 running/pending)。
2802
+ // 活跃态拒绝,避免误杀正在跑的实例。
2803
+ app.post('/api/workbench/tasks/:id/reset-shell', async (req, res) => {
2804
+ try {
2805
+ const taskId = req.params.id
2806
+ if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
2807
+ // 检查活跃 job
2808
+ const live = []
2809
+ for (const j of jobs.values()) {
2810
+ if (j.taskId !== taskId) continue
2811
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id)
2812
+ }
2813
+ if (live.length > 0) {
2814
+ return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
2815
+ }
2816
+ // 1) 清空 jobs(内存 + 磁盘)
2817
+ const removedJobIds = []
2818
+ for (const j of jobs.values()) {
2819
+ if (j.taskId !== taskId) continue
2820
+ jobs.delete(j.id)
2821
+ removedJobIds.push(j.id)
2822
+ }
2823
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
2824
+ if (jobsData && Array.isArray(jobsData.jobs)) {
2825
+ const before = jobsData.jobs.length
2826
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
2827
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
2828
+ }
2829
+ // 2) 重置 task 字段:只留 id / title / createdAt / status / projectPath(如存在)
2830
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] })
2831
+ const task = (tasksData.tasks || []).find(t => t.id === taskId)
2832
+ if (!task) {
2833
+ return res.json({ success: true, removedJobs: removedJobIds.length, message: '任务不存在,仅清空 job' })
2834
+ }
2835
+ const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0
2836
+ const removedAttCount = Array.isArray(task.attachments) ? task.attachments.length : 0
2837
+ const hadDesc = !!(task.desc && task.desc.length > 0)
2838
+ const hadPrompt = !!task.promptId
2839
+ // 保留字段:id / title / createdAt / status / projectPath
2840
+ // 清理:desc / attachments / promptId / subtasks
2841
+ const preservedProjectPath = task.projectPath
2842
+ const preservedCreatedAt = task.createdAt
2843
+ const preservedStatus = task.status || 'todo'
2844
+ // 重建对象(顺序保留 title 在前,符合视觉习惯)
2845
+ const newTask = { id: task.id, title: task.title, status: preservedStatus }
2846
+ if (preservedProjectPath) newTask.projectPath = preservedProjectPath
2847
+ if (preservedCreatedAt) newTask.createdAt = preservedCreatedAt
2848
+ newTask.subtasks = []
2849
+ newTask.attachments = []
2850
+ newTask.updatedAt = nowIso()
2851
+ // 用 Object.assign 替换 task 的所有自身属性,保留原型链
2852
+ for (const k of Object.keys(task)) delete task[k]
2853
+ Object.assign(task, newTask)
2854
+ await writeJson(TASKS_FILE, tasksData)
2855
+ publish('task:update', task)
2856
+ res.json({
2857
+ success: true,
2858
+ removedJobs: removedJobIds.length,
2859
+ removedSubs: removedSubCount,
2860
+ removedAttachments: removedAttCount,
2861
+ clearedDesc: hadDesc,
2862
+ clearedPrompt: hadPrompt,
2863
+ message: `已清空:删除 ${removedSubCount} 个子任务${removedAttCount ? '、' + removedAttCount + ' 个附件' : ''}${hadDesc ? '、任务描述' : ''},任务标题保留`
2864
+ })
2865
+ } catch (err) {
2866
+ res.status(500).json({ success: false, error: '清空任务内容失败: ' + (err.message || String(err)) })
2867
+ }
2868
+ })
2869
+
2470
2870
  // GET /api/workbench/jobs/:id
2471
2871
  app.get('/api/workbench/jobs/:id', async (req, res) => {
2472
2872
  try {