zen-gitsync 2.13.15 → 2.13.18

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 (74) hide show
  1. package/README.md +12 -2
  2. package/package.json +7 -4
  3. package/src/ui/public/assets/AppVersionBadge-CB05za9z.js +2 -0
  4. package/src/ui/public/assets/AppVersionBadge-DVn027LE.css +1 -0
  5. package/src/ui/public/assets/BranchSelector-D_4ZN-yW.js +1 -0
  6. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
  7. package/src/ui/public/assets/CommandConsole-C8gqasqx.css +1 -0
  8. package/src/ui/public/assets/CommandConsole-DnlEYvxJ.js +17 -0
  9. package/src/ui/public/assets/CommitForm-Cul_GlXI.css +1 -0
  10. package/src/ui/public/assets/CommitForm-D1fzmTVM.js +6 -0
  11. package/src/ui/public/assets/CustomCommandManager-DD_pmoPh.css +1 -0
  12. package/src/ui/public/assets/CustomCommandManager-DqbDslM5.js +1 -0
  13. package/src/ui/public/assets/EditorView-DyUPi7oi.js +0 -0
  14. package/src/ui/public/assets/FileDiffViewer-ASPmnTdG.css +1 -0
  15. package/src/ui/public/assets/FileDiffViewer-DqHNm1Db.js +13 -0
  16. package/src/ui/public/assets/FlowExecutionViewer-Ctoxm9hD.js +1 -0
  17. package/src/ui/public/assets/FlowExecutionViewer-DhmllmKH.css +1 -0
  18. package/src/ui/public/assets/FlowOrchestrationWorkspace-D-e_cp10.js +21 -0
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DmBxt5nC.css +1 -0
  20. package/src/ui/public/assets/GitStatus-C0G0_pfB.js +6 -0
  21. package/src/ui/public/assets/GitStatus-xdzfREd4.css +1 -0
  22. package/src/ui/public/assets/ImagePreview-CbW7sjEX.css +1 -0
  23. package/src/ui/public/assets/ImagePreview-IGNG85_Z.js +1 -0
  24. package/src/ui/public/assets/LogList-Cn7V9bX-.css +1 -0
  25. package/src/ui/public/assets/LogList-Drugd5s2.js +7 -0
  26. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
  27. package/src/ui/public/assets/ProjectStartupButton-DgYOZGWj.js +1 -0
  28. package/src/ui/public/assets/RemoteRepoCard-CqKjbqCc.js +1 -0
  29. package/src/ui/public/assets/RemoteRepoCard-DkcYbJtw.css +1 -0
  30. package/src/ui/public/assets/ResetToRemoteButton-DQuq2KfL.css +1 -0
  31. package/src/ui/public/assets/ResetToRemoteButton-Dquw8Q8M.js +1 -0
  32. package/src/ui/public/assets/SourceMapView-BK4SE8Sk.js +3 -0
  33. package/src/ui/public/assets/TemplateManager-BeB5DnzM.js +1 -0
  34. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
  35. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
  36. package/src/ui/public/assets/UserInputNode-DGj443LF.js +1 -0
  37. package/src/ui/public/assets/WorkbenchView-B2O2Ya-O.js +9 -0
  38. package/src/ui/public/assets/WorkbenchView-BqOJlWXm.css +1 -0
  39. package/src/ui/public/assets/_plugin-vue_export-helper-7JKb0AkH.js +9 -0
  40. package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
  41. package/src/ui/public/assets/dagre-CWIGcX9d.js +1 -0
  42. package/src/ui/public/assets/element-plus-CJjFCgLP.js +26 -0
  43. package/src/ui/public/assets/element-plus-D36sOm0a.css +1 -0
  44. package/src/ui/public/assets/fileIcon-B5p5pGN9.js +1 -0
  45. package/src/ui/public/assets/flow-mindmap-D04wFa2s.js +21 -0
  46. package/src/ui/public/assets/flow-mindmap-l8sIPoN8.css +1 -0
  47. package/src/ui/public/assets/index-CVkvp_5b.css +1 -0
  48. package/src/ui/public/assets/index-gT8ZUxDS.js +7 -0
  49. package/src/ui/public/assets/monaco-DC7mqInI.js +1201 -0
  50. package/src/ui/public/assets/monaco-UXx6YYK1.css +1 -0
  51. package/src/ui/public/assets/socket-io-z4n873W1.js +1 -0
  52. package/src/ui/public/assets/vendor-0YbzMuxw.js +226 -0
  53. package/src/ui/public/assets/vendor-DNI_idR2.css +1 -0
  54. package/src/ui/public/assets/vue-flow-CavUTEzp.css +1 -0
  55. package/src/ui/public/assets/vue-flow-HHfhvx4N.js +39 -0
  56. package/src/ui/public/index.html +10 -5
  57. package/src/ui/server/index.js +2 -1
  58. package/src/ui/server/routes/exec.js +10 -9
  59. package/src/ui/server/routes/status.js +16 -17
  60. package/src/ui/server/routes/terminal.js +12 -13
  61. package/src/ui/server/routes/workbench.js +262 -12
  62. package/src/ui/server/utils/shellQuote.js +68 -0
  63. package/src/ui/server/utils/shellQuote.test.js +110 -0
  64. package/src/utils/index.js +60 -29
  65. package/src/utils/parseCwdArg.test.js +86 -0
  66. package/src/ui/public/assets/EditorView-nLJuJf8I.js +0 -0
  67. package/src/ui/public/assets/SourceMapView-BfQkRIld.js +0 -3
  68. package/src/ui/public/assets/WorkbenchView-DBm4y9hO.css +0 -1
  69. package/src/ui/public/assets/WorkbenchView-DadqVl2m.js +0 -9
  70. package/src/ui/public/assets/_plugin-vue_export-helper-FEqtfvX2.js +0 -9
  71. package/src/ui/public/assets/index-BB8LQ04I.css +0 -1
  72. package/src/ui/public/assets/index-YZlo31ni.js +0 -69
  73. package/src/ui/public/assets/vendor-1pu0KtT-.js +0 -1508
  74. package/src/ui/public/assets/vendor-B99WlFa_.css +0 -1
@@ -123,20 +123,21 @@ export function registerExecRoutes({
123
123
  // npm、node、git 等现代工具都输出 UTF-8
124
124
  const isWindows = process.platform === 'win32';
125
125
  const cmdBuiltins = ['dir', 'type', 'set', 'path', 'cd', 'md', 'rd', 'del', 'copy', 'move', 'ren'];
126
-
126
+
127
127
  // echo 命令特殊处理:如果包含变量替换(如 {{xxx}}),说明内容可能已经是 UTF-8,不转换
128
- const isEchoCommand = command.trim().toLowerCase().startsWith('echo ');
128
+ const trimmed = command.trim().toLowerCase();
129
+ const firstToken = trimmed.split(/\s+/, 1)[0] || '';
130
+ const isEchoCommand = firstToken === 'echo';
129
131
  const hasVariableSubstitution = isEchoCommand && (
130
- command.includes('{{') ||
131
- command.includes('${') ||
132
+ command.includes('{{') ||
133
+ command.includes('${') ||
132
134
  command.includes('%') // Windows 环境变量
133
135
  );
134
-
136
+
137
+ // 必须按 token 边界判断,避免 'dir' 误匹配 'directory'、'path' 误匹配 'path-to'
135
138
  const needsGbkConversion = isWindows && (
136
- cmdBuiltins.some(builtin =>
137
- command.trim().toLowerCase().startsWith(builtin + ' ') ||
138
- command.trim().toLowerCase() === builtin
139
- ) || (isEchoCommand && !hasVariableSubstitution) // echo 只在没有变量替换时才转换
139
+ cmdBuiltins.includes(firstToken) ||
140
+ (isEchoCommand && !hasVariableSubstitution) // echo 只在没有变量替换时才转换
140
141
  );
141
142
 
142
143
  console.log(`[流式输出] 命令: ${command.substring(0, 50)}, 需要GBK转换: ${needsGbkConversion}`);
@@ -33,32 +33,31 @@ export function registerStatusRoutes({
33
33
  app.get('/api/status_porcelain', async (req, res) => {
34
34
  try {
35
35
  const { stdout } = await execGitCommand('git status --porcelain --untracked-files=all');
36
- // 检测是否处于 MERGING 状态(MERGE_HEAD 文件存在)
36
+ // 检测是否处于 MERGING 状态(MERGE_HEAD 存在)
37
37
  let isMergeInProgress = false;
38
38
  let mergeMessage = '';
39
39
  try {
40
40
  const { stdout: mergeHead } = await execGitCommand('git rev-parse -q --verify MERGE_HEAD');
41
41
  isMergeInProgress = mergeHead.trim().length > 0;
42
- if (isMergeInProgress) {
43
- // 读取 Git 自动生成的合并提交信息
44
- try {
45
- const { stdout: gitDir } = await execGitCommand('git rev-parse --git-dir');
46
- const mergeMsgPath = path.resolve(gitDir.trim(), 'MERGE_MSG');
47
- const raw = await fs.readFile(mergeMsgPath, 'utf-8');
48
- // 过滤掉以 # 开头的注释行,取第一个非空行
49
- mergeMessage = raw
50
- .split('\n')
51
- .filter(line => line.trim() && !line.startsWith('#'))
52
- .join('\n')
53
- .trim();
54
- } catch (_) {
55
- // MERGE_MSG 不存在时忽略
56
- }
57
- }
58
42
  } catch (_) {
59
43
  // MERGE_HEAD 不存在时命令会报错,属正常情况
60
44
  isMergeInProgress = false;
61
45
  }
46
+ if (isMergeInProgress) {
47
+ try {
48
+ const { stdout: gitDir } = await execGitCommand('git rev-parse --git-dir');
49
+ const mergeMsgPath = path.resolve(gitDir.trim(), 'MERGE_MSG');
50
+ const raw = await fs.readFile(mergeMsgPath, 'utf-8');
51
+ // 过滤掉以 # 开头的注释行,拼接所有非空非注释行(保留多段信息如合并分支列表)
52
+ mergeMessage = raw
53
+ .split('\n')
54
+ .filter(line => line.trim() && !line.startsWith('#'))
55
+ .join('\n')
56
+ .trim();
57
+ } catch (_) {
58
+ // MERGE_MSG 不存在/无权限时忽略
59
+ }
60
+ }
62
61
  res.json({ status: stdout, isMergeInProgress, mergeMessage });
63
62
  } catch (error) {
64
63
  res.status(500).json({ error: error.message });
@@ -13,6 +13,7 @@
13
13
  // limitations under the License.
14
14
  //
15
15
  import { spawn, exec } from 'child_process';
16
+ import { psEscape, appleEscape, shQuote } from '../utils/shellQuote.js';
16
17
 
17
18
  export function registerTerminalRoutes({
18
19
  app,
@@ -25,7 +26,6 @@ export function registerTerminalRoutes({
25
26
 
26
27
  if (process.platform === 'win32') {
27
28
  const cmdToRun = String(command || '').trim();
28
- const safeWorkingDir = String(targetDir).replace(/"/g, '""');
29
29
 
30
30
  const splitArgs = (input) => {
31
31
  const s = String(input || '');
@@ -59,6 +59,7 @@ export function registerTerminalRoutes({
59
59
  const tokens = splitArgs(cmdToRun);
60
60
  const isStartCommand = tokens.length > 0 && String(tokens[0]).toLowerCase() === 'start';
61
61
 
62
+ // PowerShell 转义全部走 shellQuote.psEscape ($ ` " 都会被处理)
62
63
  let psScript;
63
64
  if (isStartCommand) {
64
65
  const args = tokens.slice(1);
@@ -69,19 +70,14 @@ export function registerTerminalRoutes({
69
70
  const exe = candidateExe && !isUrl(candidateExe) ? String(candidateExe) : null;
70
71
 
71
72
  if (!exe && candidateUrl) {
72
- const safeUrl = String(candidateUrl).replace(/"/g, '""');
73
- psScript = `$p = Start-Process -FilePath "${safeUrl}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
73
+ psScript = `$p = Start-Process -FilePath "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
74
74
  } else if (exe && candidateUrl) {
75
- const safeExe = String(exe).replace(/"/g, '""');
76
- const safeUrl = String(candidateUrl).replace(/"/g, '""');
77
- psScript = `$p = Start-Process -FilePath "${safeExe}" -ArgumentList "${safeUrl}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
75
+ psScript = `$p = Start-Process -FilePath "${psEscape(exe)}" -ArgumentList "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
78
76
  } else {
79
- const safeCmd = String(cmdToRun).replace(/"/g, '""');
80
- psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${safeCmd}" -WorkingDirectory "${safeWorkingDir}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
77
+ psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
81
78
  }
82
79
  } else {
83
- const safeCmd = String(cmdToRun).replace(/"/g, '""');
84
- psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${safeCmd}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
80
+ psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
85
81
  }
86
82
 
87
83
  return await new Promise((resolve, reject) => {
@@ -118,8 +114,10 @@ export function registerTerminalRoutes({
118
114
  }
119
115
 
120
116
  if (process.platform === 'darwin') {
121
- const script = `tell application "Terminal" to do script "cd ${targetDir} && ${command.trim()}"`;
122
- exec(`osascript -e '${script}'`, (error) => {
117
+ // AppleScript 转义统一走 shellQuote.appleEscape (\ " 都会被处理)
118
+ const script = `tell application "Terminal" to do script "cd ${appleEscape(targetDir)} && ${appleEscape(command.trim())}"`;
119
+ // 外层用单引号包,把脚本里可能出现的单引号转义为 '\''
120
+ exec(`osascript -e ${shQuote(script)}`, (error) => {
123
121
  if (error) {
124
122
  console.error('打开终端失败:', error);
125
123
  }
@@ -127,7 +125,8 @@ export function registerTerminalRoutes({
127
125
  return { pid: null };
128
126
  }
129
127
 
130
- const terminalCommand = `gnome-terminal -- bash -c "cd ${targetDir} && ${command.trim()}; exec bash" || xterm -e "cd ${targetDir} && ${command.trim()}; bash"`;
128
+ // Linux: sh 单引号包命令行,单引号内不解释 $ ` " \ ,只把单引号自身 '\''
129
+ const terminalCommand = `gnome-terminal -- bash -c ${shQuote(`cd ${targetDir} && ${command.trim()}; exec bash`)} || xterm -e ${shQuote(`cd ${targetDir} && ${command.trim()}; bash`)}`;
131
130
  exec(terminalCommand, (error) => {
132
131
  if (error) {
133
132
  console.error('打开终端失败:', error);
@@ -1070,20 +1070,35 @@ function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
1070
1070
  });
1071
1071
  }
1072
1072
 
1073
- // 顺序执行一个任务下所有子任务;上一个结束再启动下一个
1074
- async function runTaskQueue(task, repoPath, branch) {
1075
- // 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
1076
- // 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
1077
- // 故意不用 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 已经习惯"摘要 + 关键结论"的格式,且不会
1078
1083
  // 一次塞几 MB 进 prompt 烧 token。truncate 到每条 MAX_PREV_OUTPUT_CHARS。
1079
1084
  const MAX_PREV_OUTPUT_CHARS = 2000
1080
- const priorOutputs = []
1081
- 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]
1082
1096
  if (sub.status === 'done') continue;
1083
1097
  await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
1098
+ // 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
1099
+ // sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
1100
+ await persistTaskAfterRun(task)
1084
1101
  }
1085
- // 写回 tasks.json
1086
- await persistTaskAfterRun(task)
1087
1102
  }
1088
1103
 
1089
1104
  /**
@@ -1324,11 +1339,19 @@ async function persistTaskAfterRun(task) {
1324
1339
  * 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
1325
1340
  */
1326
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) {
1327
1352
  const MAX_PREV_OUTPUT_CHARS = 2000
1328
1353
  const prior = []
1329
- const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
1330
- if (targetIdx < 0) return prior
1331
- for (let i = 0; i < targetIdx; i++) {
1354
+ for (let i = 0; i < endIdx; i++) {
1332
1355
  const s = task.subtasks[i]
1333
1356
  if (s.status !== 'done') continue
1334
1357
  // 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
@@ -2305,6 +2328,40 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2305
2328
  }
2306
2329
  });
2307
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
+
2308
2365
  // ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
2309
2366
  // POST /api/workbench/tasks/:id/run-simple
2310
2367
  // 行为:
@@ -2672,6 +2729,199 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2672
2729
  }
2673
2730
  })
2674
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
+
2870
+ // POST /api/workbench/tasks/:id/clear-subtasks
2871
+ // 只清空 subtasks 数组(及关联的 jobs),保留 desc / attachments / promptId / projectPath。
2872
+ // 与 reset-shell 的区别:reset-shell 把任务还原成只有标题的空壳,这个只删拆分结果。
2873
+ // 活跃 job 拒绝,避免误杀正在跑的实例。
2874
+ app.post('/api/workbench/tasks/:id/clear-subtasks', async (req, res) => {
2875
+ try {
2876
+ const taskId = req.params.id
2877
+ if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
2878
+ // 1) 检查活跃 job
2879
+ const live = []
2880
+ for (const j of jobs.values()) {
2881
+ if (j.taskId !== taskId) continue
2882
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id)
2883
+ }
2884
+ if (live.length > 0) {
2885
+ return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
2886
+ }
2887
+ // 2) 清空该 task 的所有 jobs(内存 + 磁盘)
2888
+ const removedJobIds = []
2889
+ for (const j of jobs.values()) {
2890
+ if (j.taskId !== taskId) continue
2891
+ jobs.delete(j.id)
2892
+ removedJobIds.push(j.id)
2893
+ }
2894
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
2895
+ if (jobsData && Array.isArray(jobsData.jobs)) {
2896
+ const before = jobsData.jobs.length
2897
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
2898
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
2899
+ }
2900
+ // 3) 更新 task:只清 subtasks,其他字段(尤其 desc / attachments / promptId)保留
2901
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] })
2902
+ const task = (tasksData.tasks || []).find(t => t.id === taskId)
2903
+ if (!task) {
2904
+ return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '任务不存在,仅清空 job' })
2905
+ }
2906
+ const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0
2907
+ if (removedSubCount === 0) {
2908
+ return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '没有子任务需要清空' })
2909
+ }
2910
+ task.subtasks = []
2911
+ task.updatedAt = nowIso()
2912
+ await writeJson(TASKS_FILE, tasksData)
2913
+ publish('task:update', task)
2914
+ res.json({
2915
+ success: true,
2916
+ removedJobs: removedJobIds.length,
2917
+ removedSubs: removedSubCount,
2918
+ message: `已清空 ${removedSubCount} 个子任务,任务描述和附件保留`
2919
+ })
2920
+ } catch (err) {
2921
+ res.status(500).json({ success: false, error: '清空子任务失败: ' + (err.message || String(err)) })
2922
+ }
2923
+ })
2924
+
2675
2925
  // GET /api/workbench/jobs/:id
2676
2926
  app.get('/api/workbench/jobs/:id', async (req, res) => {
2677
2927
  try {
@@ -0,0 +1,68 @@
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
+ * Shell / PowerShell 字符串转义工具
18
+ *
19
+ * 提供三种转义策略:
20
+ * - shQuote: POSIX shell 单引号包字符串(git add / osascript / gnome-terminal 用)
21
+ * - psEscape: PowerShell 双引号内安全转义(terminal.js 的 PowerShell 脚本用)
22
+ * - appleEscape: AppleScript 双引号字符串内安全转义(macOS 终端用)
23
+ *
24
+ * 三种都不依赖外部包,纯字符串操作。
25
+ *
26
+ * @typedef {string | number | null | undefined} ShellInput
27
+ */
28
+
29
+ /**
30
+ * POSIX shell 单引号包裹
31
+ * 单引号内不解释 $ ` " \,只把单引号自身转义为 '\''
32
+ * 例: shQuote('a"b$c') === "'a\"b$c'"
33
+ *
34
+ * @param {ShellInput} input
35
+ * @returns {string}
36
+ */
37
+ export function shQuote(input) {
38
+ if (input === null || input === undefined) return "''"
39
+ return `'${String(input).replace(/'/g, `'\\''`)}'`
40
+ }
41
+
42
+ /**
43
+ * PowerShell 双引号字符串内安全转义
44
+ * 需要把 $ (变量/子表达式) 和 ` (子表达式) 各自前置一个反引号
45
+ * 双引号本身用 "" 转义;反引号自身先用 `` 转义避免吃掉后续转义
46
+ *
47
+ * @param {ShellInput} input
48
+ * @returns {string}
49
+ */
50
+ export function psEscape(input) {
51
+ if (input === null || input === undefined) return '""'
52
+ return String(input)
53
+ .replace(/`/g, '``')
54
+ .replace(/\$/g, '`$')
55
+ .replace(/"/g, '""')
56
+ }
57
+
58
+ /**
59
+ * AppleScript 双引号字符串内安全转义
60
+ * 反斜杠和双引号都需要前置反斜杠
61
+ *
62
+ * @param {ShellInput} input
63
+ * @returns {string}
64
+ */
65
+ export function appleEscape(input) {
66
+ if (input === null || input === undefined) return '""'
67
+ return String(input).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
68
+ }
@@ -0,0 +1,110 @@
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
+ // shellQuote 单元测试(node:test 内置)
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { shQuote, psEscape, appleEscape } from '../utils/shellQuote.js'
19
+
20
+ test('shQuote 普通字符串', () => {
21
+ assert.equal(shQuote('hello'), "'hello'")
22
+ })
23
+
24
+ test('shQuote 单引号需要转义', () => {
25
+ // POSIX shell: 单引号字符串里唯一需要处理的就是单引号自身
26
+ // 关闭单引号 + 转义 + 开启单引号 = '\''
27
+ assert.equal(shQuote("a'b"), "'a'\\''b'")
28
+ })
29
+
30
+ test('shQuote 注入字符也能合法包裹', () => {
31
+ // POSIX shell 单引号内 $ ` " \ 都不展开,所以原样包住就是安全的
32
+ // 函数职责只保证产出一个语法合法的单引号字符串
33
+ const evil = '$(rm -rf /); `whoami` "$PATH" \\path'
34
+ const quoted = shQuote(evil)
35
+ // 必须以单引号开头和结尾,内部任何单引号都被 '\'' 转义
36
+ assert.ok(quoted.startsWith("'"))
37
+ assert.ok(quoted.endsWith("'"))
38
+ // 内部裸的单引号不会破坏字符串:
39
+ // 统计未配对的单引号数量,被正确转义的 ' 必须成对出现 '\''
40
+ // 这里因为输入不含单引号,所以 quoted 形如 '<evil>'
41
+ assert.equal(quoted.length, evil.length + 2)
42
+ })
43
+
44
+ test('shQuote null / undefined / number 都安全', () => {
45
+ assert.equal(shQuote(null), "''")
46
+ assert.equal(shQuote(undefined), "''")
47
+ assert.equal(shQuote(42), "'42'")
48
+ })
49
+
50
+ test('shQuote 文件名场景:含空格和双引号', () => {
51
+ // 模拟 git status --porcelain 解出的文件名
52
+ assert.equal(shQuote('my file.txt'), "'my file.txt'")
53
+ assert.equal(shQuote('a"b.txt'), `'a"b.txt'`)
54
+ })
55
+
56
+ test('psEscape $ 变量必须前置反引号', () => {
57
+ // 原: $(rm -rf /) → 期望: `$(rm -rf /)
58
+ // (PowerShell 双引号内 $ 会触发子表达式求值,前置 ` 取消求值)
59
+ assert.equal(psEscape('$(rm -rf /)'), '`$(rm -rf /)')
60
+ })
61
+
62
+ test('psEscape 反引号自身先转义', () => {
63
+ // 反引号是 PowerShell 转义符,先自身转义避免吃掉后续的转义符
64
+ // 原: `n → 期望: ``n
65
+ assert.equal(psEscape('`n'), '``n')
66
+ // 原: $`n (3 字符) → 期望: `$``n (5 字符: ` $ ` ` n)
67
+ // 反引号先转: $``n (4) → 然后 $ 前置: `$``n (5)
68
+ assert.equal(psEscape('$`n'), '`$``n')
69
+ })
70
+
71
+ test('psEscape 双引号用 "" 转义', () => {
72
+ assert.equal(psEscape('a"b'), 'a""b')
73
+ })
74
+
75
+ test('psEscape 普通字符串不变', () => {
76
+ assert.equal(psEscape('hello world'), 'hello world')
77
+ })
78
+
79
+ test('psEscape null / undefined → 空字符串', () => {
80
+ assert.equal(psEscape(null), '""')
81
+ assert.equal(psEscape(undefined), '""')
82
+ })
83
+
84
+ test('psEscape 工作目录路径 (Windows 含反斜杠和 $)', () => {
85
+ // PowerShell 里 $env:TEMP 是变量; $ 开头的路径是注入面
86
+ const path = '$env:TEMP\\evil$(rm).txt'
87
+ const escaped = psEscape(path)
88
+ // 所有 $ 必须前置反引号
89
+ assert.ok(!escaped.match(/(?<!`)\$/), '不应有未转义的 $')
90
+ assert.ok(escaped.includes('`$env:'))
91
+ assert.ok(escaped.includes('`$(rm)'))
92
+ })
93
+
94
+ test('appleEscape 双引号前置反斜杠', () => {
95
+ assert.equal(appleEscape('a"b'), 'a\\"b')
96
+ })
97
+
98
+ test('appleEscape 反斜杠先转义', () => {
99
+ // 反斜杠要双写避免吃掉后面的 "
100
+ assert.equal(appleEscape('a\\"b'), 'a\\\\\\"b')
101
+ })
102
+
103
+ test('appleEscape 普通字符串不变', () => {
104
+ assert.equal(appleEscape('cd /tmp && ls'), 'cd /tmp && ls')
105
+ })
106
+
107
+ test('appleEscape null / undefined → 空字符串', () => {
108
+ assert.equal(appleEscape(null), '""')
109
+ assert.equal(appleEscape(undefined), '""')
110
+ })