clawt 3.5.3 → 3.6.0

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.
package/dist/index.js CHANGED
@@ -311,7 +311,17 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
311
311
  /** 批量 resume 非 macOS 平台提示 */
312
312
  RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
313
313
  /** 批量 resume 无匹配分支提示 */
314
- RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
314
+ RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`,
315
+ /** --prompt 必须配合 -b 指定目标分支 */
316
+ RESUME_PROMPT_REQUIRES_BRANCH: "--prompt \u5FC5\u987B\u914D\u5408 -b \u6307\u5B9A\u76EE\u6807\u5206\u652F",
317
+ /** --prompt 和 -f 不能同时使用 */
318
+ RESUME_PROMPT_FILE_CONFLICT: "--prompt \u548C -f \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528",
319
+ /** 未找到对应 worktree */
320
+ RESUME_WORKTREE_NOT_FOUND: (branch, available) => `\u672A\u627E\u5230\u5206\u652F "${branch}" \u5BF9\u5E94\u7684 worktree
321
+ \u53EF\u7528\u5206\u652F\uFF1A
322
+ ${available.map((b) => ` - ${b}`).join("\n")}`,
323
+ /** 追问文件加载完成 */
324
+ RESUME_FOLLOW_UP_FILE_LOADED: (count, path2) => `\u4ECE ${path2} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u8FFD\u95EE\u4EFB\u52A1`
315
325
  };
316
326
 
317
327
  // src/constants/messages/remove.ts
@@ -2762,10 +2772,14 @@ function parseStreamEvent(event) {
2762
2772
  }
2763
2773
 
2764
2774
  // src/utils/task-executor.ts
2765
- function executeClaudeTask(worktree, task, onActivity) {
2775
+ function executeClaudeTask(worktree, task, onActivity, continueSession) {
2776
+ const args = ["-p", task, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"];
2777
+ if (continueSession) {
2778
+ args.push("--continue");
2779
+ }
2766
2780
  const child = spawnProcess(
2767
2781
  "claude",
2768
- ["-p", task, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"],
2782
+ args,
2769
2783
  {
2770
2784
  cwd: worktree.path,
2771
2785
  // stdin 必须设置为 'ignore',不能用 'pipe'
@@ -2875,7 +2889,7 @@ function updateRendererStatus(renderer, index, result, startTime) {
2875
2889
  );
2876
2890
  }
2877
2891
  }
2878
- async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses) {
2892
+ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags) {
2879
2893
  const total = tasks.length;
2880
2894
  const results = new Array(total);
2881
2895
  let nextIndex = 0;
@@ -2891,7 +2905,7 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
2891
2905
  renderer.markRunning(index);
2892
2906
  const handle = executeClaudeTask(wt, task, (activityText) => {
2893
2907
  renderer.updateActivityText(index, activityText);
2894
- });
2908
+ }, continueFlags?.[index]);
2895
2909
  childProcesses.push(handle.child);
2896
2910
  handle.promise.then((result) => {
2897
2911
  results[index] = result;
@@ -2911,13 +2925,13 @@ async function executeWithConcurrency(worktrees, tasks, concurrency, renderer, s
2911
2925
  }
2912
2926
  });
2913
2927
  }
2914
- async function executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses) {
2928
+ async function executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags) {
2915
2929
  const handles = worktrees.map((wt, index) => {
2916
2930
  const task = tasks[index];
2917
2931
  logger.info(`\u542F\u52A8\u4EFB\u52A1 ${index + 1}: ${task} (worktree: ${wt.path})`);
2918
2932
  const handle = executeClaudeTask(wt, task, (activityText) => {
2919
2933
  renderer.updateActivityText(index, activityText);
2920
- });
2934
+ }, continueFlags?.[index]);
2921
2935
  childProcesses.push(handle.child);
2922
2936
  return handle;
2923
2937
  });
@@ -2933,8 +2947,13 @@ async function executeAllParallel(worktrees, tasks, renderer, startTime, isInter
2933
2947
  );
2934
2948
  return results;
2935
2949
  }
2936
- async function executeBatchTasks(worktrees, tasks, concurrency) {
2950
+ async function executeBatchTasks(worktrees, tasks, concurrency, continueFlags) {
2937
2951
  const count = tasks.length;
2952
+ if (continueFlags && continueFlags.length !== count) {
2953
+ throw new ClawtError(
2954
+ `continueFlags \u957F\u5EA6 (${continueFlags.length}) \u4E0E\u4EFB\u52A1\u6570 (${count}) \u4E0D\u4E00\u81F4`
2955
+ );
2956
+ }
2938
2957
  if (concurrency > 0) {
2939
2958
  printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
2940
2959
  printInfo("");
@@ -2968,10 +2987,10 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
2968
2987
  process.exit(1);
2969
2988
  };
2970
2989
  process.on("SIGINT", sigintHandler);
2971
- const results = concurrency > 0 ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses) : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
2990
+ const results = concurrency > 0 ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags) : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags);
2972
2991
  renderer.stop();
2973
2992
  process.removeListener("SIGINT", sigintHandler);
2974
- if (interrupted) return;
2993
+ if (interrupted) return [];
2975
2994
  const totalDurationMs = Date.now() - startTime;
2976
2995
  const summary = {
2977
2996
  total: results.length,
@@ -2981,6 +3000,7 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
2981
3000
  totalCostUsd: results.reduce((sum, r) => sum + (r.result?.total_cost_usd ?? 0), 0)
2982
3001
  };
2983
3002
  printTaskSummary(summary);
3003
+ return results;
2984
3004
  }
2985
3005
 
2986
3006
  // src/utils/dry-run.ts
@@ -4439,12 +4459,26 @@ var RESUME_RESOLVE_MESSAGES = {
4439
4459
  noMatch: MESSAGES.RESUME_NO_MATCH
4440
4460
  };
4441
4461
  function registerResumeCommand(program2) {
4442
- program2.command("resume").description("\u5728\u5DF2\u6709 worktree \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF08\u652F\u6301\u591A\u9009\u6279\u91CF\u6062\u590D\uFF09").option("-b, --branch <branchName>", "\u8981\u6062\u590D\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").action(async (options) => {
4462
+ program2.command("resume").description("\u5728\u5DF2\u6709 worktree \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF08\u652F\u6301\u591A\u9009\u6279\u91CF\u6062\u590D\uFF09").option("-b, --branch <branchName>", "\u8981\u6062\u590D\u7684\u5206\u652F\u540D\uFF08\u652F\u6301\u6A21\u7CCA\u5339\u914D\uFF0C\u4E0D\u4F20\u5219\u5217\u51FA\u6240\u6709\u5206\u652F\uFF09").option("--prompt <content>", "\u975E\u4EA4\u4E92\u5F0F\u8FFD\u95EE\uFF08\u9700\u914D\u5408 -b \u6307\u5B9A\u5206\u652F\uFF09").option("-f, --file <path>", "\u4ECE\u4EFB\u52A1\u6587\u4EF6\u6279\u91CF\u8FFD\u95EE\uFF08\u901A\u8FC7 branch \u540D\u5339\u914D\u5DF2\u6709 worktree\uFF09").option("-c, --concurrency <n>", "\u6279\u91CF\u8FFD\u95EE\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236").action(async (options) => {
4443
4463
  await handleResume(options);
4444
4464
  });
4445
4465
  }
4446
4466
  async function handleResume(options) {
4447
4467
  await runPreChecks(PRE_CHECK_RESUME);
4468
+ if (options.prompt && options.file) {
4469
+ throw new ClawtError(MESSAGES.RESUME_PROMPT_FILE_CONFLICT);
4470
+ }
4471
+ if (options.prompt) {
4472
+ if (!options.branch) {
4473
+ throw new ClawtError(MESSAGES.RESUME_PROMPT_REQUIRES_BRANCH);
4474
+ }
4475
+ const worktrees2 = getProjectWorktrees();
4476
+ const worktree = resolveWorktreeByBranch(options.branch, worktrees2);
4477
+ return handleNonInteractiveSingleResume(worktree, options.prompt);
4478
+ }
4479
+ if (options.file) {
4480
+ return handleNonInteractiveBatchResume(options.file, options);
4481
+ }
4448
4482
  logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F\u8FC7\u6EE4: ${options.branch ?? "(\u65E0)"}`);
4449
4483
  const worktrees = getProjectWorktrees();
4450
4484
  let targetWorktrees;
@@ -4468,6 +4502,35 @@ async function handleResume(options) {
4468
4502
  await handleBatchResume(targetWorktrees);
4469
4503
  }
4470
4504
  }
4505
+ function resolveWorktreeByBranch(branch, worktrees) {
4506
+ const match = findExactMatch(worktrees, branch);
4507
+ if (!match) {
4508
+ const available = worktrees.map((wt) => wt.branch);
4509
+ throw new ClawtError(MESSAGES.RESUME_WORKTREE_NOT_FOUND(branch, available));
4510
+ }
4511
+ return match;
4512
+ }
4513
+ async function handleNonInteractiveSingleResume(worktree, prompt) {
4514
+ const hasPrevious = hasClaudeSessionHistory(worktree.path);
4515
+ await executeBatchTasks([worktree], [prompt], 0, [hasPrevious]);
4516
+ }
4517
+ async function handleNonInteractiveBatchResume(filePath, options) {
4518
+ const entries = loadTaskFile(filePath, { branchRequired: true });
4519
+ printSuccess(MESSAGES.RESUME_FOLLOW_UP_FILE_LOADED(entries.length, filePath));
4520
+ const allWorktrees = getProjectWorktrees();
4521
+ const worktrees = [];
4522
+ const tasks = [];
4523
+ const continueFlags = [];
4524
+ for (const entry of entries) {
4525
+ const worktree = resolveWorktreeByBranch(entry.branch, allWorktrees);
4526
+ worktrees.push(worktree);
4527
+ tasks.push(entry.task);
4528
+ continueFlags.push(hasClaudeSessionHistory(worktree.path));
4529
+ }
4530
+ const concurrency = parseConcurrency(options.concurrency, getConfigValue("maxConcurrency"));
4531
+ logger.info(`resume \u547D\u4EE4\uFF08\u6279\u91CF\u8FFD\u95EE\u6A21\u5F0F\uFF09\u6267\u884C\uFF0C\u4EFB\u52A1\u6570: ${entries.length}\uFF0C\u5E76\u53D1\u6570: ${concurrency || "\u4E0D\u9650\u5236"}`);
4532
+ await executeBatchTasks(worktrees, tasks, concurrency, continueFlags);
4533
+ }
4471
4534
  function printBatchResumePreview(worktrees, sessionMap) {
4472
4535
  printInfo("\u5373\u5C06\u6062\u590D\u7684\u5206\u652F\uFF1A");
4473
4536
  for (const wt of worktrees) {
@@ -302,7 +302,17 @@ ${branches.map((b) => ` - ${b}`).join("\n")}`,
302
302
  /** 批量 resume 非 macOS 平台提示 */
303
303
  RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
304
304
  /** 批量 resume 无匹配分支提示 */
305
- RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
305
+ RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`,
306
+ /** --prompt 必须配合 -b 指定目标分支 */
307
+ RESUME_PROMPT_REQUIRES_BRANCH: "--prompt \u5FC5\u987B\u914D\u5408 -b \u6307\u5B9A\u76EE\u6807\u5206\u652F",
308
+ /** --prompt 和 -f 不能同时使用 */
309
+ RESUME_PROMPT_FILE_CONFLICT: "--prompt \u548C -f \u4E0D\u80FD\u540C\u65F6\u4F7F\u7528",
310
+ /** 未找到对应 worktree */
311
+ RESUME_WORKTREE_NOT_FOUND: (branch, available) => `\u672A\u627E\u5230\u5206\u652F "${branch}" \u5BF9\u5E94\u7684 worktree
312
+ \u53EF\u7528\u5206\u652F\uFF1A
313
+ ${available.map((b) => ` - ${b}`).join("\n")}`,
314
+ /** 追问文件加载完成 */
315
+ RESUME_FOLLOW_UP_FILE_LOADED: (count, path) => `\u4ECE ${path} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u8FFD\u95EE\u4EFB\u52A1`
306
316
  };
307
317
 
308
318
  // src/constants/messages/remove.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.5.3",
3
+ "version": "3.6.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import { logger } from '../logger/index.js';
3
+ import { ClawtError } from '../errors/index.js';
3
4
  import { MESSAGES } from '../constants/index.js';
4
5
  import type { ResumeOptions } from '../types/index.js';
5
6
  import type { WorktreeInfo } from '../types/index.js';
@@ -12,10 +13,14 @@ import {
12
13
  hasClaudeSessionHistory,
13
14
  resolveTargetWorktrees,
14
15
  promptGroupedMultiSelectBranches,
16
+ findExactMatch,
15
17
  printInfo,
16
18
  printSuccess,
17
19
  confirmAction,
18
20
  getConfigValue,
21
+ parseConcurrency,
22
+ loadTaskFile,
23
+ executeBatchTasks,
19
24
  } from '../utils/index.js';
20
25
  import type { WorktreeMultiResolveMessages } from '../utils/index.js';
21
26
 
@@ -36,6 +41,9 @@ export function registerResumeCommand(program: Command): void {
36
41
  .command('resume')
37
42
  .description('在已有 worktree 中恢复 Claude Code 会话(支持多选批量恢复)')
38
43
  .option('-b, --branch <branchName>', '要恢复的分支名(支持模糊匹配,不传则列出所有分支)')
44
+ .option('--prompt <content>', '非交互式追问(需配合 -b 指定分支)')
45
+ .option('-f, --file <path>', '从任务文件批量追问(通过 branch 名匹配已有 worktree)')
46
+ .option('-c, --concurrency <n>', '批量追问最大并发数,0 表示不限制')
39
47
  .action(async (options: ResumeOptions) => {
40
48
  await handleResume(options);
41
49
  });
@@ -49,6 +57,25 @@ export function registerResumeCommand(program: Command): void {
49
57
  async function handleResume(options: ResumeOptions): Promise<void> {
50
58
  await runPreChecks(PRE_CHECK_RESUME);
51
59
 
60
+ // 非交互式追问逻辑分支
61
+ if (options.prompt && options.file) {
62
+ throw new ClawtError(MESSAGES.RESUME_PROMPT_FILE_CONFLICT);
63
+ }
64
+
65
+ if (options.prompt) {
66
+ if (!options.branch) {
67
+ throw new ClawtError(MESSAGES.RESUME_PROMPT_REQUIRES_BRANCH);
68
+ }
69
+ const worktrees = getProjectWorktrees();
70
+ const worktree = resolveWorktreeByBranch(options.branch, worktrees);
71
+ return handleNonInteractiveSingleResume(worktree, options.prompt);
72
+ }
73
+
74
+ if (options.file) {
75
+ return handleNonInteractiveBatchResume(options.file, options);
76
+ }
77
+
78
+ // 原有交互式逻辑
52
79
  logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '(无)'}`);
53
80
  const worktrees = getProjectWorktrees();
54
81
 
@@ -82,6 +109,70 @@ async function handleResume(options: ResumeOptions): Promise<void> {
82
109
  }
83
110
  }
84
111
 
112
+ /**
113
+ * 精确匹配分支对应的 worktree
114
+ * 找不到时抛出包含可用分支列表的错误
115
+ * @param {string} branch - 目标分支名
116
+ * @param {WorktreeInfo[]} worktrees - 可用的 worktree 列表
117
+ * @returns {WorktreeInfo} 匹配的 worktree
118
+ * @throws {ClawtError} 未找到匹配分支时抛出
119
+ */
120
+ function resolveWorktreeByBranch(branch: string, worktrees: WorktreeInfo[]): WorktreeInfo {
121
+ const match = findExactMatch(worktrees, branch);
122
+ if (!match) {
123
+ const available = worktrees.map((wt) => wt.branch);
124
+ throw new ClawtError(MESSAGES.RESUME_WORKTREE_NOT_FOUND(branch, available));
125
+ }
126
+ return match;
127
+ }
128
+
129
+ /**
130
+ * 非交互式单分支追问
131
+ * 检查 worktree 是否有历史会话,有则通过 --continue 继续最新会话
132
+ * @param {WorktreeInfo} worktree - 目标 worktree
133
+ * @param {string} prompt - 追问内容
134
+ */
135
+ async function handleNonInteractiveSingleResume(worktree: WorktreeInfo, prompt: string): Promise<void> {
136
+ // 检查是否有历史会话,有则追加 --continue
137
+ const hasPrevious = hasClaudeSessionHistory(worktree.path);
138
+ await executeBatchTasks([worktree], [prompt], 0, [hasPrevious]);
139
+ }
140
+
141
+ /**
142
+ * 非交互式批量追问
143
+ * 从任务文件解析追问内容,按 branch 名精确匹配已有 worktree,批量执行
144
+ * @param {string} filePath - 追问任务文件路径
145
+ * @param {ResumeOptions} options - 命令选项(含并发数配置)
146
+ */
147
+ async function handleNonInteractiveBatchResume(filePath: string, options: ResumeOptions): Promise<void> {
148
+ // 解析追问文件,branch 为必填字段
149
+ const entries = loadTaskFile(filePath, { branchRequired: true });
150
+ printSuccess(MESSAGES.RESUME_FOLLOW_UP_FILE_LOADED(entries.length, filePath));
151
+
152
+ // 获取所有已有 worktree
153
+ const allWorktrees = getProjectWorktrees();
154
+
155
+ // 按 branch 名精确匹配 worktree,构建执行参数
156
+ const worktrees: WorktreeInfo[] = [];
157
+ const tasks: string[] = [];
158
+ const continueFlags: boolean[] = [];
159
+
160
+ for (const entry of entries) {
161
+ const worktree = resolveWorktreeByBranch(entry.branch!, allWorktrees);
162
+ worktrees.push(worktree);
163
+ tasks.push(entry.task);
164
+ // 按 worktree 独立检查是否有历史会话
165
+ continueFlags.push(hasClaudeSessionHistory(worktree.path));
166
+ }
167
+
168
+ // 解析并发数
169
+ const concurrency = parseConcurrency(options.concurrency, getConfigValue('maxConcurrency'));
170
+
171
+ logger.info(`resume 命令(批量追问模式)执行,任务数: ${entries.length},并发数: ${concurrency || '不限制'}`);
172
+
173
+ await executeBatchTasks(worktrees, tasks, concurrency, continueFlags);
174
+ }
175
+
85
176
  /**
86
177
  * 输出即将恢复的分支列表(含会话状态:继续/新对话)
87
178
  * @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
@@ -18,4 +18,14 @@ export const RESUME_MESSAGES = {
18
18
  RESUME_ALL_PLATFORM_UNSUPPORTED: '批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)',
19
19
  /** 批量 resume 无匹配分支提示 */
20
20
  RESUME_ALL_NO_MATCH: (keyword: string) => `未找到与 "${keyword}" 匹配的分支`,
21
+
22
+ /** --prompt 必须配合 -b 指定目标分支 */
23
+ RESUME_PROMPT_REQUIRES_BRANCH: '--prompt 必须配合 -b 指定目标分支',
24
+ /** --prompt 和 -f 不能同时使用 */
25
+ RESUME_PROMPT_FILE_CONFLICT: '--prompt 和 -f 不能同时使用',
26
+ /** 未找到对应 worktree */
27
+ RESUME_WORKTREE_NOT_FOUND: (branch: string, available: string[]) =>
28
+ `未找到分支 "${branch}" 对应的 worktree\n 可用分支:\n${available.map((b) => ` - ${b}`).join('\n')}`,
29
+ /** 追问文件加载完成 */
30
+ RESUME_FOLLOW_UP_FILE_LOADED: (count: number, path: string) => `从 ${path} 加载了 ${count} 个追问任务`,
21
31
  } as const;
@@ -52,6 +52,12 @@ export interface RemoveOptions {
52
52
  export interface ResumeOptions {
53
53
  /** 要恢复的分支名(可选,不传则列出所有分支供选择) */
54
54
  branch?: string;
55
+ /** 非交互式追问内容(需配合 -b 指定分支) */
56
+ prompt?: string;
57
+ /** 从任务文件批量追问(通过 branch 名匹配已有 worktree) */
58
+ file?: string;
59
+ /** 批量追问最大并发数,0 表示不限制(Commander 传入为字符串) */
60
+ concurrency?: string;
55
61
  }
56
62
 
57
63
  /** sync 命令选项 */
@@ -9,6 +9,7 @@ import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAct
9
9
  import { ProgressRenderer } from './progress.js';
10
10
  import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
11
11
  import { RESULT_PREVIEW_MAX_LENGTH } from '../constants/index.js';
12
+ import { ClawtError } from '../errors/index.js';
12
13
 
13
14
  /** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
14
15
  interface ClaudeTaskHandle {
@@ -29,13 +30,21 @@ type ActivityCallback = (activityText: string) => void;
29
30
  * @param {WorktreeInfo} worktree - worktree 信息
30
31
  * @param {string} task - 任务描述
31
32
  * @param {ActivityCallback} [onActivity] - 活动更新回调(可选)
33
+ * @param {boolean} [continueSession] - 是否继续该 worktree 目录下最新的会话
32
34
  * @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
33
35
  */
34
- function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback): ClaudeTaskHandle {
36
+ function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback, continueSession?: boolean): ClaudeTaskHandle {
35
37
  // 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
38
+ const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'];
39
+
40
+ // 追问模式:追加 --continue 继续该目录下最新会话
41
+ if (continueSession) {
42
+ args.push('--continue');
43
+ }
44
+
36
45
  const child = spawnProcess(
37
46
  'claude',
38
- ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'],
47
+ args,
39
48
  {
40
49
  cwd: worktree.path,
41
50
  // stdin 必须设置为 'ignore',不能用 'pipe'
@@ -198,6 +207,7 @@ function updateRendererStatus(renderer: ProgressRenderer, index: number, result:
198
207
  * @param {number} startTime - 任务批次启动时间戳
199
208
  * @param {() => boolean} isInterrupted - 检查是否已中断的函数
200
209
  * @param {ChildProcess[]} childProcesses - 共享子进程数组,执行过程中动态追加
210
+ * @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
201
211
  * @returns {Promise<TaskResult[]>} 所有任务结果
202
212
  */
203
213
  async function executeWithConcurrency(
@@ -208,6 +218,7 @@ async function executeWithConcurrency(
208
218
  startTime: number,
209
219
  isInterrupted: () => boolean,
210
220
  childProcesses: ChildProcess[],
221
+ continueFlags?: boolean[],
211
222
  ): Promise<TaskResult[]> {
212
223
  const total = tasks.length;
213
224
  const results: TaskResult[] = new Array(total);
@@ -234,7 +245,7 @@ async function executeWithConcurrency(
234
245
 
235
246
  const handle = executeClaudeTask(wt, task, (activityText) => {
236
247
  renderer.updateActivityText(index, activityText);
237
- });
248
+ }, continueFlags?.[index]);
238
249
  childProcesses.push(handle.child);
239
250
 
240
251
  handle.promise.then((result) => {
@@ -272,6 +283,7 @@ async function executeWithConcurrency(
272
283
  * @param {number} startTime - 任务批次启动时间戳
273
284
  * @param {() => boolean} isInterrupted - 检查是否已中断的函数
274
285
  * @param {ChildProcess[]} childProcesses - 共享子进程数组,启动时追加
286
+ * @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
275
287
  * @returns {Promise<TaskResult[]>} 所有任务结果
276
288
  */
277
289
  async function executeAllParallel(
@@ -281,13 +293,14 @@ async function executeAllParallel(
281
293
  startTime: number,
282
294
  isInterrupted: () => boolean,
283
295
  childProcesses: ChildProcess[],
296
+ continueFlags?: boolean[],
284
297
  ): Promise<TaskResult[]> {
285
298
  const handles = worktrees.map((wt, index) => {
286
299
  const task = tasks[index];
287
300
  logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
288
301
  const handle = executeClaudeTask(wt, task, (activityText) => {
289
302
  renderer.updateActivityText(index, activityText);
290
- });
303
+ }, continueFlags?.[index]);
291
304
  childProcesses.push(handle.child);
292
305
 
293
306
  return handle;
@@ -314,14 +327,25 @@ async function executeAllParallel(
314
327
  * @param {WorktreeInfo[]} worktrees - worktree 列表
315
328
  * @param {string[]} tasks - 任务描述列表
316
329
  * @param {number} concurrency - 最大并发数,0 表示不限制
330
+ * @param {boolean[]} [continueFlags] - 按索引对应每个任务是否继续已有会话(追问模式,追加 --continue)
331
+ * @returns {Promise<TaskResult[]>} 所有任务的执行结果
332
+ * @throws {ClawtError} continueFlags 长度与任务数不一致时抛出
317
333
  */
318
334
  export async function executeBatchTasks(
319
335
  worktrees: WorktreeInfo[],
320
336
  tasks: string[],
321
337
  concurrency: number,
322
- ): Promise<void> {
338
+ continueFlags?: boolean[],
339
+ ): Promise<TaskResult[]> {
323
340
  const count = tasks.length;
324
341
 
342
+ // 校验 continueFlags 长度与 worktrees 一致,防止调用方传入不匹配的数组
343
+ if (continueFlags && continueFlags.length !== count) {
344
+ throw new ClawtError(
345
+ `continueFlags 长度 (${continueFlags.length}) 与任务数 (${count}) 不一致`,
346
+ );
347
+ }
348
+
325
349
  // 有并发限制时输出提示
326
350
  if (concurrency > 0) {
327
351
  printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
@@ -374,15 +398,15 @@ export async function executeBatchTasks(
374
398
 
375
399
  // 根据并发限制选择执行模式
376
400
  const results = concurrency > 0
377
- ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses)
378
- : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
401
+ ? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses, continueFlags)
402
+ : await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses, continueFlags);
379
403
 
380
404
  // 正常完成,停止进度面板并移除 SIGINT 监听器
381
405
  renderer.stop();
382
406
  process.removeListener('SIGINT', sigintHandler);
383
407
 
384
408
  // 被中断时不输出汇总(已在 sigintHandler 中处理退出)
385
- if (interrupted) return;
409
+ if (interrupted) return [];
386
410
 
387
411
  const totalDurationMs = Date.now() - startTime;
388
412
 
@@ -396,4 +420,6 @@ export async function executeBatchTasks(
396
420
  };
397
421
 
398
422
  printTaskSummary(summary);
423
+
424
+ return results;
399
425
  }
@@ -5,6 +5,16 @@ vi.mock('../../../src/logger/index.js', () => ({
5
5
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
6
  }));
7
7
 
8
+ vi.mock('../../../src/errors/index.js', () => ({
9
+ ClawtError: class ClawtError extends Error {
10
+ exitCode: number;
11
+ constructor(message: string, exitCode = 1) {
12
+ super(message);
13
+ this.exitCode = exitCode;
14
+ }
15
+ },
16
+ }));
17
+
8
18
  vi.mock('../../../src/constants/index.js', async (importOriginal) => {
9
19
  const actual = await importOriginal<typeof import('../../../src/constants/index.js')>();
10
20
  return {
@@ -16,6 +26,11 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
16
26
  RESUME_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
17
27
  RESUME_ALL_CONFIRM: (count: number) => `确认恢复 ${count} 个分支?`,
18
28
  RESUME_ALL_SUCCESS: (count: number) => `已恢复 ${count} 个分支`,
29
+ RESUME_PROMPT_REQUIRES_BRANCH: '--prompt 必须配合 -b 指定目标分支',
30
+ RESUME_PROMPT_FILE_CONFLICT: '--prompt 和 -f 不能同时使用',
31
+ RESUME_WORKTREE_NOT_FOUND: (branch: string, available: string[]) => `未找到分支 "${branch}" 对应的 worktree`,
32
+ RESUME_FOLLOW_UP_FILE_LOADED: (count: number, path: string) => `从 ${path} 加载了 ${count} 个追问任务`,
33
+ CONCURRENCY_INFO: (concurrency: number, total: number) => `并发限制: ${concurrency},共 ${total} 个任务`,
19
34
  },
20
35
  };
21
36
  });
@@ -29,48 +44,64 @@ vi.mock('../../../src/utils/index.js', () => ({
29
44
  hasClaudeSessionHistory: vi.fn(),
30
45
  resolveTargetWorktrees: vi.fn(),
31
46
  promptGroupedMultiSelectBranches: vi.fn(),
47
+ findExactMatch: vi.fn(),
32
48
  printInfo: vi.fn(),
33
49
  printSuccess: vi.fn(),
50
+ printWarning: vi.fn(),
34
51
  confirmAction: vi.fn(),
35
52
  getConfigValue: vi.fn(),
53
+ parseConcurrency: vi.fn().mockReturnValue(0),
54
+ loadTaskFile: vi.fn(),
55
+ executeBatchTasks: vi.fn().mockResolvedValue([]),
36
56
  }));
37
57
 
38
58
  import { registerResumeCommand } from '../../../src/commands/resume.js';
39
59
  import {
40
60
  runPreChecks,
41
- validateClaudeCodeInstalled,
42
61
  getProjectWorktrees,
43
62
  launchInteractiveClaude,
44
63
  launchInteractiveClaudeInNewTerminal,
45
64
  hasClaudeSessionHistory,
46
65
  resolveTargetWorktrees,
47
66
  promptGroupedMultiSelectBranches,
67
+ findExactMatch,
48
68
  confirmAction,
49
69
  getConfigValue,
70
+ parseConcurrency,
71
+ loadTaskFile,
72
+ executeBatchTasks,
50
73
  } from '../../../src/utils/index.js';
51
74
 
52
75
  const mockedRunPreChecks = vi.mocked(runPreChecks);
53
- const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
54
76
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
55
77
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
56
78
  const mockedLaunchInteractiveClaudeInNewTerminal = vi.mocked(launchInteractiveClaudeInNewTerminal);
57
79
  const mockedHasClaudeSessionHistory = vi.mocked(hasClaudeSessionHistory);
58
80
  const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
59
81
  const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
82
+ const mockedFindExactMatch = vi.mocked(findExactMatch);
60
83
  const mockedConfirmAction = vi.mocked(confirmAction);
61
84
  const mockedGetConfigValue = vi.mocked(getConfigValue);
85
+ const mockedParseConcurrency = vi.mocked(parseConcurrency);
86
+ const mockedLoadTaskFile = vi.mocked(loadTaskFile);
87
+ const mockedExecuteBatchTasks = vi.mocked(executeBatchTasks);
62
88
 
63
89
  beforeEach(() => {
64
90
  mockedRunPreChecks.mockReset();
65
- mockedValidateClaudeCodeInstalled.mockReset();
66
91
  mockedGetProjectWorktrees.mockReset();
67
92
  mockedLaunchInteractiveClaude.mockReset();
68
93
  mockedLaunchInteractiveClaudeInNewTerminal.mockReset();
69
94
  mockedHasClaudeSessionHistory.mockReset();
70
95
  mockedResolveTargetWorktrees.mockReset();
71
96
  mockedPromptGroupedMultiSelectBranches.mockReset();
97
+ mockedFindExactMatch.mockReset();
72
98
  mockedConfirmAction.mockReset();
73
99
  mockedGetConfigValue.mockReset();
100
+ mockedParseConcurrency.mockReset();
101
+ mockedParseConcurrency.mockReturnValue(0);
102
+ mockedLoadTaskFile.mockReset();
103
+ mockedExecuteBatchTasks.mockReset();
104
+ mockedExecuteBatchTasks.mockResolvedValue([]);
74
105
  });
75
106
 
76
107
  describe('registerResumeCommand', () => {
@@ -80,6 +111,16 @@ describe('registerResumeCommand', () => {
80
111
  const cmd = program.commands.find((c) => c.name() === 'resume');
81
112
  expect(cmd).toBeDefined();
82
113
  });
114
+
115
+ it('注册 --prompt、-f、-c 选项', () => {
116
+ const program = new Command();
117
+ registerResumeCommand(program);
118
+ const cmd = program.commands.find((c) => c.name() === 'resume');
119
+ const options = cmd!.options.map((o) => o.long);
120
+ expect(options).toContain('--prompt');
121
+ expect(options).toContain('--file');
122
+ expect(options).toContain('--concurrency');
123
+ });
83
124
  });
84
125
 
85
126
  describe('handleResume', () => {
@@ -226,3 +267,200 @@ describe('handleResume — resumeInPlace 配置', () => {
226
267
  expect(mockedGetConfigValue).not.toHaveBeenCalled();
227
268
  });
228
269
  });
270
+
271
+ describe('handleResume — 非交互式追问', () => {
272
+ it('--prompt + -b 有历史会话时传 [true]', async () => {
273
+ const worktree = { path: '/path/feature', branch: 'feature' };
274
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
275
+ mockedFindExactMatch.mockReturnValue(worktree);
276
+ mockedHasClaudeSessionHistory.mockReturnValue(true);
277
+ mockedExecuteBatchTasks.mockResolvedValue([]);
278
+
279
+ const program = new Command();
280
+ program.exitOverride();
281
+ registerResumeCommand(program);
282
+ await program.parseAsync(['resume', '-b', 'feature', '--prompt', '加上单元测试'], { from: 'user' });
283
+
284
+ expect(mockedFindExactMatch).toHaveBeenCalled();
285
+ expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
286
+ // 有历史会话时使用 --continue 模式
287
+ expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
288
+ [worktree],
289
+ ['加上单元测试'],
290
+ 0,
291
+ [true],
292
+ );
293
+ // 不应走交互式流程
294
+ expect(mockedResolveTargetWorktrees).not.toHaveBeenCalled();
295
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it('--prompt + -b 无历史会话时传 [false]', async () => {
299
+ const worktree = { path: '/path/feature', branch: 'feature' };
300
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
301
+ mockedFindExactMatch.mockReturnValue(worktree);
302
+ mockedHasClaudeSessionHistory.mockReturnValue(false);
303
+ mockedExecuteBatchTasks.mockResolvedValue([]);
304
+
305
+ const program = new Command();
306
+ program.exitOverride();
307
+ registerResumeCommand(program);
308
+ await program.parseAsync(['resume', '-b', 'feature', '--prompt', '加上单元测试'], { from: 'user' });
309
+
310
+ expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
311
+ // 无历史会话时不传 --continue
312
+ expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
313
+ [worktree],
314
+ ['加上单元测试'],
315
+ 0,
316
+ [false],
317
+ );
318
+ });
319
+
320
+ it('--prompt 无 -b 时报错', async () => {
321
+ const program = new Command();
322
+ program.exitOverride();
323
+ registerResumeCommand(program);
324
+
325
+ await expect(
326
+ program.parseAsync(['resume', '--prompt', '加上单元测试'], { from: 'user' }),
327
+ ).rejects.toThrow('--prompt 必须配合 -b 指定目标分支');
328
+ });
329
+
330
+ it('--prompt 和 -f 同时使用时报错', async () => {
331
+ const program = new Command();
332
+ program.exitOverride();
333
+ registerResumeCommand(program);
334
+
335
+ await expect(
336
+ program.parseAsync(['resume', '-b', 'feature', '--prompt', '追问', '-f', 'tasks.md'], { from: 'user' }),
337
+ ).rejects.toThrow('--prompt 和 -f 不能同时使用');
338
+ });
339
+
340
+ it('--prompt 指定的分支不存在时报错', async () => {
341
+ mockedGetProjectWorktrees.mockReturnValue([
342
+ { path: '/path/other', branch: 'other' },
343
+ ]);
344
+ mockedFindExactMatch.mockReturnValue(undefined);
345
+
346
+ const program = new Command();
347
+ program.exitOverride();
348
+ registerResumeCommand(program);
349
+
350
+ await expect(
351
+ program.parseAsync(['resume', '-b', 'nonexistent', '--prompt', '追问'], { from: 'user' }),
352
+ ).rejects.toThrow('未找到分支');
353
+ });
354
+
355
+ it('-f 批量追问模式', async () => {
356
+ const worktrees = [
357
+ { path: '/path/feat-a', branch: 'feat-a' },
358
+ { path: '/path/feat-b', branch: 'feat-b' },
359
+ ];
360
+ mockedLoadTaskFile.mockReturnValue([
361
+ { branch: 'feat-a', task: '追问任务A' },
362
+ { branch: 'feat-b', task: '追问任务B' },
363
+ ]);
364
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
365
+ mockedFindExactMatch
366
+ .mockReturnValueOnce(worktrees[0])
367
+ .mockReturnValueOnce(worktrees[1]);
368
+ mockedHasClaudeSessionHistory.mockReturnValue(true);
369
+ mockedExecuteBatchTasks.mockResolvedValue([]);
370
+
371
+ const program = new Command();
372
+ program.exitOverride();
373
+ registerResumeCommand(program);
374
+ await program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' });
375
+
376
+ expect(mockedLoadTaskFile).toHaveBeenCalledWith('follow-up.md', { branchRequired: true });
377
+ // 按 worktree 独立检查会话历史
378
+ expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith('/path/feat-a');
379
+ expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith('/path/feat-b');
380
+ expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
381
+ worktrees,
382
+ ['追问任务A', '追问任务B'],
383
+ 0,
384
+ [true, true],
385
+ );
386
+ });
387
+
388
+ it('-f 批量追问分支不存在时报错', async () => {
389
+ mockedLoadTaskFile.mockReturnValue([
390
+ { branch: 'nonexistent', task: '追问任务' },
391
+ ]);
392
+ mockedGetProjectWorktrees.mockReturnValue([
393
+ { path: '/path/feat-a', branch: 'feat-a' },
394
+ ]);
395
+ mockedFindExactMatch.mockReturnValue(undefined);
396
+
397
+ const program = new Command();
398
+ program.exitOverride();
399
+ registerResumeCommand(program);
400
+
401
+ await expect(
402
+ program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' }),
403
+ ).rejects.toThrow('未找到分支');
404
+ });
405
+
406
+ it('-f + -c 传递并发数', async () => {
407
+ const worktree = { path: '/path/feat-a', branch: 'feat-a' };
408
+ mockedLoadTaskFile.mockReturnValue([
409
+ { branch: 'feat-a', task: '追问' },
410
+ ]);
411
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
412
+ mockedFindExactMatch.mockReturnValue(worktree);
413
+ mockedHasClaudeSessionHistory.mockReturnValue(true);
414
+ mockedParseConcurrency.mockReturnValue(2);
415
+ mockedExecuteBatchTasks.mockResolvedValue([]);
416
+
417
+ const program = new Command();
418
+ program.exitOverride();
419
+ registerResumeCommand(program);
420
+ await program.parseAsync(['resume', '-f', 'follow-up.md', '-c', '2'], { from: 'user' });
421
+
422
+ expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
423
+ [worktree],
424
+ ['追问'],
425
+ 2,
426
+ [true],
427
+ );
428
+ });
429
+
430
+ it('-f 批量追问按 worktree 独立检查会话历史', async () => {
431
+ const worktrees = [
432
+ { path: '/path/feat-a', branch: 'feat-a' },
433
+ { path: '/path/feat-b', branch: 'feat-b' },
434
+ { path: '/path/feat-c', branch: 'feat-c' },
435
+ ];
436
+ mockedLoadTaskFile.mockReturnValue([
437
+ { branch: 'feat-a', task: '任务A' },
438
+ { branch: 'feat-b', task: '任务B' },
439
+ { branch: 'feat-c', task: '任务C' },
440
+ ]);
441
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
442
+ mockedFindExactMatch
443
+ .mockReturnValueOnce(worktrees[0])
444
+ .mockReturnValueOnce(worktrees[1])
445
+ .mockReturnValueOnce(worktrees[2]);
446
+ // feat-a 有历史会话,feat-b 无,feat-c 有
447
+ mockedHasClaudeSessionHistory
448
+ .mockReturnValueOnce(true)
449
+ .mockReturnValueOnce(false)
450
+ .mockReturnValueOnce(true);
451
+ mockedExecuteBatchTasks.mockResolvedValue([]);
452
+
453
+ const program = new Command();
454
+ program.exitOverride();
455
+ registerResumeCommand(program);
456
+ await program.parseAsync(['resume', '-f', 'follow-up.md'], { from: 'user' });
457
+
458
+ // continueFlags 应按 worktree 独立反映各自的会话历史状态
459
+ expect(mockedExecuteBatchTasks).toHaveBeenCalledWith(
460
+ worktrees,
461
+ ['任务A', '任务B', '任务C'],
462
+ 0,
463
+ [true, false, true],
464
+ );
465
+ });
466
+ });