clawt 2.10.1 → 2.11.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/.claude/agent-memory/docs-sync-updater/MEMORY.md +5 -1
- package/README.md +25 -2
- package/dist/index.js +603 -170
- package/dist/postinstall.js +31 -1
- package/docs/spec.md +57 -8
- package/package.json +1 -1
- package/src/commands/run.ts +68 -206
- package/src/constants/config.ts +4 -0
- package/src/constants/index.ts +10 -0
- package/src/constants/messages/run.ts +30 -0
- package/src/constants/progress.ts +39 -0
- package/src/types/command.ts +4 -0
- package/src/types/config.ts +2 -0
- package/src/types/index.ts +1 -0
- package/src/types/taskFile.ts +13 -0
- package/src/utils/formatter.ts +16 -0
- package/src/utils/index.ts +6 -2
- package/src/utils/progress-render.ts +90 -0
- package/src/utils/progress.ts +213 -0
- package/src/utils/task-executor.ts +365 -0
- package/src/utils/task-file.ts +87 -0
- package/src/utils/worktree.ts +27 -0
- package/tests/unit/commands/run.test.ts +259 -10
- package/tests/unit/constants/config.test.ts +1 -0
- package/tests/unit/utils/formatter.test.ts +27 -1
- package/tests/unit/utils/progress.test.ts +255 -0
- package/tests/unit/utils/task-file.test.ts +236 -0
- package/tests/unit/utils/worktree.test.ts +26 -1
package/src/utils/index.ts
CHANGED
|
@@ -47,12 +47,16 @@ export {
|
|
|
47
47
|
} from './git.js';
|
|
48
48
|
export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
|
|
49
49
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
|
|
50
|
-
export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus } from './worktree.js';
|
|
50
|
+
export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
|
|
51
51
|
export { loadConfig, writeDefaultConfig, getConfigValue, ensureClawtDirs } from './config.js';
|
|
52
|
-
export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle } from './formatter.js';
|
|
52
|
+
export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
|
|
53
53
|
export { ensureDir, removeEmptyDir } from './fs.js';
|
|
54
54
|
export { multilineInput } from './prompt.js';
|
|
55
55
|
export { launchInteractiveClaude } from './claude.js';
|
|
56
56
|
export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
57
57
|
export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
|
|
58
58
|
export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
|
|
59
|
+
export { ProgressRenderer } from './progress.js';
|
|
60
|
+
export { parseTaskFile, loadTaskFile } from './task-file.js';
|
|
61
|
+
export { executeBatchTasks } from './task-executor.js';
|
|
62
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { TASK_STATUS_ICONS, TASK_STATUS_LABELS } from '../constants/index.js';
|
|
3
|
+
import { formatDuration } from './formatter.js';
|
|
4
|
+
|
|
5
|
+
/** 单个任务的进度状态 */
|
|
6
|
+
export interface TaskProgress {
|
|
7
|
+
/** 任务序号(从 1 开始) */
|
|
8
|
+
index: number;
|
|
9
|
+
/** 分支名(用于显示) */
|
|
10
|
+
branch: string;
|
|
11
|
+
/** worktree 路径(完成/失败后显示,终端可点击跳转) */
|
|
12
|
+
path: string;
|
|
13
|
+
/** 任务状态 */
|
|
14
|
+
status: 'pending' | 'running' | 'done' | 'failed';
|
|
15
|
+
/** 任务启动时间戳 */
|
|
16
|
+
startedAt: number;
|
|
17
|
+
/** 任务完成时间戳 */
|
|
18
|
+
finishedAt: number | null;
|
|
19
|
+
/** 最后活动时间戳(stderr 有输出时更新) */
|
|
20
|
+
lastActiveAt: number;
|
|
21
|
+
/** 耗时(毫秒),完成后由 ClaudeCodeResult 填入 */
|
|
22
|
+
durationMs: number | null;
|
|
23
|
+
/** 费用(美元),完成后由 ClaudeCodeResult 填入 */
|
|
24
|
+
costUsd: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 计算分支名的最大显示宽度,用于对齐
|
|
29
|
+
* @param {TaskProgress[]} tasks - 任务列表
|
|
30
|
+
* @returns {number} 最大分支名长度
|
|
31
|
+
*/
|
|
32
|
+
export function getMaxBranchWidth(tasks: TaskProgress[]): number {
|
|
33
|
+
return Math.max(...tasks.map((t) => t.branch.length));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 渲染单个任务行(TTY 模式)
|
|
38
|
+
* 格式: [1/3] feat-1 ⠹ 运行中 1m23s
|
|
39
|
+
* 完成/失败后追加 worktree 路径,终端可点击跳转
|
|
40
|
+
* @param {TaskProgress} task - 任务进度
|
|
41
|
+
* @param {number} total - 总任务数
|
|
42
|
+
* @param {number} maxBranchWidth - 分支名最大宽度(用于对齐)
|
|
43
|
+
* @param {string} spinnerChar - 当前 spinner 帧字符
|
|
44
|
+
* @returns {string} 渲染后的单行字符串(含 chalk 颜色)
|
|
45
|
+
*/
|
|
46
|
+
export function renderTaskLine(task: TaskProgress, total: number, maxBranchWidth: number, spinnerChar: string): string {
|
|
47
|
+
const indexStr = `[${task.index}/${total}]`;
|
|
48
|
+
const branchStr = task.branch.padEnd(maxBranchWidth);
|
|
49
|
+
|
|
50
|
+
switch (task.status) {
|
|
51
|
+
case 'pending': {
|
|
52
|
+
return `${indexStr} ${branchStr} ${chalk.gray(TASK_STATUS_ICONS.PENDING)} ${chalk.gray(TASK_STATUS_LABELS.PENDING)} ${chalk.dim(task.path)}`;
|
|
53
|
+
}
|
|
54
|
+
case 'running': {
|
|
55
|
+
const elapsed = formatDuration(Date.now() - task.startedAt);
|
|
56
|
+
return `${indexStr} ${branchStr} ${chalk.cyan(spinnerChar)} ${chalk.cyan(TASK_STATUS_LABELS.RUNNING)} ${chalk.gray(elapsed)} ${chalk.dim(task.path)}`;
|
|
57
|
+
}
|
|
58
|
+
case 'done': {
|
|
59
|
+
const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
|
|
60
|
+
const cost = task.costUsd != null ? `$${task.costUsd.toFixed(2)}` : '';
|
|
61
|
+
return `${indexStr} ${branchStr} ${chalk.green(TASK_STATUS_ICONS.DONE)} ${chalk.green(TASK_STATUS_LABELS.DONE)} ${chalk.gray(duration)} ${chalk.yellow(cost)} ${chalk.dim(task.path)}`;
|
|
62
|
+
}
|
|
63
|
+
case 'failed': {
|
|
64
|
+
const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
|
|
65
|
+
return `${indexStr} ${branchStr} ${chalk.red(TASK_STATUS_ICONS.FAILED)} ${chalk.red(TASK_STATUS_LABELS.FAILED)} ${chalk.gray(duration)} ${chalk.dim(task.path)}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 渲染汇总行,统计各状态的任务数
|
|
72
|
+
* 格式: [2/8 运行中, 3/8 已完成, 3/8 排队中]
|
|
73
|
+
* @param {TaskProgress[]} tasks - 任务列表
|
|
74
|
+
* @param {number} total - 总任务数
|
|
75
|
+
* @returns {string} 汇总行字符串
|
|
76
|
+
*/
|
|
77
|
+
export function renderSummaryLine(tasks: TaskProgress[], total: number): string {
|
|
78
|
+
const running = tasks.filter((t) => t.status === 'running').length;
|
|
79
|
+
const done = tasks.filter((t) => t.status === 'done').length;
|
|
80
|
+
const failed = tasks.filter((t) => t.status === 'failed').length;
|
|
81
|
+
const pending = tasks.filter((t) => t.status === 'pending').length;
|
|
82
|
+
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
if (running > 0) parts.push(chalk.cyan(`${running}/${total} ${TASK_STATUS_LABELS.RUNNING}`));
|
|
85
|
+
if (done > 0) parts.push(chalk.green(`${done}/${total} ${TASK_STATUS_LABELS.DONE}`));
|
|
86
|
+
if (failed > 0) parts.push(chalk.red(`${failed}/${total} ${TASK_STATUS_LABELS.FAILED}`));
|
|
87
|
+
if (pending > 0) parts.push(chalk.gray(`${pending}/${total} ${TASK_STATUS_LABELS.PENDING}`));
|
|
88
|
+
|
|
89
|
+
return `[${parts.join(', ')}]`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SPINNER_FRAMES,
|
|
3
|
+
SPINNER_INTERVAL_MS,
|
|
4
|
+
CURSOR_UP,
|
|
5
|
+
CLEAR_LINE,
|
|
6
|
+
CURSOR_HIDE,
|
|
7
|
+
CURSOR_SHOW,
|
|
8
|
+
MESSAGES,
|
|
9
|
+
} from '../constants/index.js';
|
|
10
|
+
import { formatDuration } from './formatter.js';
|
|
11
|
+
import type { TaskProgress } from './progress-render.js';
|
|
12
|
+
import { getMaxBranchWidth, renderTaskLine, renderSummaryLine } from './progress-render.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 任务进度面板渲染器
|
|
16
|
+
*
|
|
17
|
+
* 职责:协调任务状态管理与终端渲染
|
|
18
|
+
* - TTY 模式:使用 ANSI 转义码实现原地多行刷新
|
|
19
|
+
* - 非 TTY 模式:降级为逐行输出,不使用 ANSI 转义
|
|
20
|
+
*/
|
|
21
|
+
export class ProgressRenderer {
|
|
22
|
+
/** 所有任务的进度状态 */
|
|
23
|
+
private tasks: TaskProgress[];
|
|
24
|
+
/** 总任务数 */
|
|
25
|
+
private total: number;
|
|
26
|
+
/** 当前 spinner 帧索引 */
|
|
27
|
+
private frameIndex: number;
|
|
28
|
+
/** 定时器引用 */
|
|
29
|
+
private timer: ReturnType<typeof setInterval> | null;
|
|
30
|
+
/** 是否为 TTY 环境 */
|
|
31
|
+
private isTTY: boolean;
|
|
32
|
+
/** 已渲染的行数(用于回退光标) */
|
|
33
|
+
private renderedLineCount: number;
|
|
34
|
+
/** 是否已停止 */
|
|
35
|
+
private stopped: boolean;
|
|
36
|
+
/** 是否存在排队任务(启用汇总行渲染) */
|
|
37
|
+
private hasPendingTasks: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 创建进度面板渲染器
|
|
41
|
+
* @param {string[]} branches - 分支名列表,顺序对应任务列表
|
|
42
|
+
* @param {string[]} paths - worktree 路径列表,完成/失败后显示
|
|
43
|
+
* @param {boolean} [allRunning=true] - 是否将所有任务初始化为 running 状态,false 时初始化为 pending
|
|
44
|
+
*/
|
|
45
|
+
constructor(branches: string[], paths: string[], allRunning: boolean = true) {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
this.total = branches.length;
|
|
48
|
+
this.frameIndex = 0;
|
|
49
|
+
this.timer = null;
|
|
50
|
+
this.isTTY = !!process.stdout.isTTY;
|
|
51
|
+
this.renderedLineCount = 0;
|
|
52
|
+
this.stopped = false;
|
|
53
|
+
this.hasPendingTasks = !allRunning;
|
|
54
|
+
|
|
55
|
+
this.tasks = branches.map((branch, i) => ({
|
|
56
|
+
index: i + 1,
|
|
57
|
+
branch,
|
|
58
|
+
path: paths[i],
|
|
59
|
+
status: allRunning ? 'running' : 'pending',
|
|
60
|
+
startedAt: allRunning ? now : 0,
|
|
61
|
+
finishedAt: null,
|
|
62
|
+
lastActiveAt: allRunning ? now : 0,
|
|
63
|
+
durationMs: null,
|
|
64
|
+
costUsd: null,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 启动定时渲染循环
|
|
70
|
+
* TTY 模式下每 SPINNER_INTERVAL_MS 毫秒刷新一次面板
|
|
71
|
+
* 非 TTY 模式下输出状态为 running 的任务的启动信息
|
|
72
|
+
*/
|
|
73
|
+
start(): void {
|
|
74
|
+
if (this.stopped) return;
|
|
75
|
+
|
|
76
|
+
if (!this.isTTY) {
|
|
77
|
+
// 非 TTY 降级:仅输出已为 running 的任务的启动信息
|
|
78
|
+
for (const task of this.tasks) {
|
|
79
|
+
if (task.status === 'running') {
|
|
80
|
+
console.log(MESSAGES.PROGRESS_TASK_STARTED(task.index, this.total, task.branch, task.path));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// TTY 模式:隐藏光标,启动定时刷新
|
|
87
|
+
process.stdout.write(CURSOR_HIDE);
|
|
88
|
+
this.render();
|
|
89
|
+
this.timer = setInterval(() => {
|
|
90
|
+
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
91
|
+
this.render();
|
|
92
|
+
}, SPINNER_INTERVAL_MS);
|
|
93
|
+
|
|
94
|
+
// 确保定时器不阻止进程退出
|
|
95
|
+
if (this.timer.unref) {
|
|
96
|
+
this.timer.unref();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 更新指定任务的最后活动时间戳
|
|
102
|
+
* 当 child.stderr 有输出时调用,表示任务仍然活跃
|
|
103
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
104
|
+
*/
|
|
105
|
+
updateActivity(index: number): void {
|
|
106
|
+
this.tasks[index].lastActiveAt = Date.now();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 标记指定任务为运行中状态
|
|
111
|
+
* 将 pending 任务标记为 running 并设置启动时间戳
|
|
112
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
113
|
+
*/
|
|
114
|
+
markRunning(index: number): void {
|
|
115
|
+
const task = this.tasks[index];
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
task.status = 'running';
|
|
118
|
+
task.startedAt = now;
|
|
119
|
+
task.lastActiveAt = now;
|
|
120
|
+
|
|
121
|
+
if (!this.isTTY) {
|
|
122
|
+
// 非 TTY 降级:输出启动信息
|
|
123
|
+
console.log(MESSAGES.PROGRESS_TASK_STARTED(task.index, this.total, task.branch, task.path));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 标记指定任务为完成状态
|
|
129
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
130
|
+
* @param {number} durationMs - 耗时(毫秒)
|
|
131
|
+
* @param {number} costUsd - 费用(美元)
|
|
132
|
+
*/
|
|
133
|
+
markDone(index: number, durationMs: number, costUsd: number): void {
|
|
134
|
+
const task = this.tasks[index];
|
|
135
|
+
task.status = 'done';
|
|
136
|
+
task.finishedAt = Date.now();
|
|
137
|
+
task.durationMs = durationMs;
|
|
138
|
+
task.costUsd = costUsd;
|
|
139
|
+
|
|
140
|
+
if (!this.isTTY) {
|
|
141
|
+
// 非 TTY 降级:直接输出完成信息
|
|
142
|
+
const duration = formatDuration(durationMs);
|
|
143
|
+
const cost = `$${costUsd.toFixed(2)}`;
|
|
144
|
+
console.log(MESSAGES.PROGRESS_TASK_DONE(task.index, this.total, task.branch, duration, cost, task.path));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 标记指定任务为失败状态
|
|
150
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
151
|
+
* @param {number} durationMs - 耗时(毫秒)
|
|
152
|
+
*/
|
|
153
|
+
markFailed(index: number, durationMs: number): void {
|
|
154
|
+
const task = this.tasks[index];
|
|
155
|
+
task.status = 'failed';
|
|
156
|
+
task.finishedAt = Date.now();
|
|
157
|
+
task.durationMs = durationMs;
|
|
158
|
+
|
|
159
|
+
if (!this.isTTY) {
|
|
160
|
+
// 非 TTY 降级:直接输出失败信息
|
|
161
|
+
const duration = formatDuration(durationMs);
|
|
162
|
+
console.log(MESSAGES.PROGRESS_TASK_FAILED(task.index, this.total, task.branch, duration, task.path));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 停止渲染循环并恢复光标
|
|
168
|
+
* 在所有任务完成或 SIGINT 中断时调用
|
|
169
|
+
*/
|
|
170
|
+
stop(): void {
|
|
171
|
+
if (this.stopped) return;
|
|
172
|
+
this.stopped = true;
|
|
173
|
+
|
|
174
|
+
if (this.timer) {
|
|
175
|
+
clearInterval(this.timer);
|
|
176
|
+
this.timer = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.isTTY) {
|
|
180
|
+
// 最后渲染一次,确保最终状态显示正确
|
|
181
|
+
this.render();
|
|
182
|
+
// 恢复光标显示
|
|
183
|
+
process.stdout.write(CURSOR_SHOW);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 执行一次完整的面板渲染
|
|
189
|
+
* 先回退光标到面板起始位置,再逐行输出
|
|
190
|
+
*/
|
|
191
|
+
private render(): void {
|
|
192
|
+
const maxBranchWidth = getMaxBranchWidth(this.tasks);
|
|
193
|
+
const spinnerChar = SPINNER_FRAMES[this.frameIndex];
|
|
194
|
+
const lines = this.tasks.map((task) => renderTaskLine(task, this.total, maxBranchWidth, spinnerChar));
|
|
195
|
+
|
|
196
|
+
// 存在排队任务时追加汇总行
|
|
197
|
+
if (this.hasPendingTasks) {
|
|
198
|
+
lines.push(renderSummaryLine(this.tasks, this.total));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 回退光标到面板起始位置
|
|
202
|
+
if (this.renderedLineCount > 0) {
|
|
203
|
+
process.stdout.write(CURSOR_UP(this.renderedLineCount));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 逐行输出,每行末尾清除残留字符
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
process.stdout.write(`${line}${CLEAR_LINE}\n`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.renderedLineCount = lines.length;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
import { logger } from '../logger/index.js';
|
|
3
|
+
import { MESSAGES } from '../constants/index.js';
|
|
4
|
+
import type { ClaudeCodeResult, TaskResult, TaskSummary, WorktreeInfo } from '../types/index.js';
|
|
5
|
+
import { spawnProcess, killAllChildProcesses } from './shell.js';
|
|
6
|
+
import { cleanupWorktrees } from './worktree.js';
|
|
7
|
+
import { getConfigValue } from './config.js';
|
|
8
|
+
import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
|
|
9
|
+
import { ProgressRenderer } from './progress.js';
|
|
10
|
+
|
|
11
|
+
/** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
|
|
12
|
+
interface ClaudeTaskHandle {
|
|
13
|
+
/** 子进程实例,用于在中断时终止 */
|
|
14
|
+
child: ChildProcess;
|
|
15
|
+
/** 任务结果 Promise */
|
|
16
|
+
promise: Promise<TaskResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 在指定 worktree 中执行 Claude Code 任务,由于是--output-format json形式,所以这里固定claude code cli的启动命令
|
|
21
|
+
* @param {WorktreeInfo} worktree - worktree 信息
|
|
22
|
+
* @param {string} task - 任务描述
|
|
23
|
+
* @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
|
|
24
|
+
*/
|
|
25
|
+
function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHandle {
|
|
26
|
+
const child = spawnProcess(
|
|
27
|
+
'claude',
|
|
28
|
+
['-p', task, '--output-format', 'json', '--permission-mode', 'bypassPermissions'],
|
|
29
|
+
{
|
|
30
|
+
cwd: worktree.path,
|
|
31
|
+
// stdin 必须设置为 'ignore',不能用 'pipe'
|
|
32
|
+
// 原因:claude -p 是非交互模式,不需要 stdin 输入。如果 stdin 为 'pipe',
|
|
33
|
+
// 父进程会创建一个可写流连接到子进程但从不写入也不关闭,
|
|
34
|
+
// claude 检测到 stdin 是管道后会尝试读取输入,导致进程永远卡住
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const promise = new Promise<TaskResult>((resolve) => {
|
|
40
|
+
let stdout = '';
|
|
41
|
+
let stderr = '';
|
|
42
|
+
|
|
43
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
44
|
+
stdout += data.toString();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
48
|
+
stderr += data.toString();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
child.on('close', (code) => {
|
|
52
|
+
let result: ClaudeCodeResult | null = null;
|
|
53
|
+
let success = code === 0;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (stdout.trim()) {
|
|
57
|
+
result = JSON.parse(stdout.trim()) as ClaudeCodeResult;
|
|
58
|
+
success = !result.is_error;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
logger.warn(`解析 Claude Code 输出失败: ${stdout.substring(0, 200)}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resolve({
|
|
65
|
+
task,
|
|
66
|
+
branch: worktree.branch,
|
|
67
|
+
worktreePath: worktree.path,
|
|
68
|
+
success,
|
|
69
|
+
result,
|
|
70
|
+
error: success ? undefined : stderr || '任务执行失败',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
child.on('error', (err) => {
|
|
75
|
+
resolve({
|
|
76
|
+
task,
|
|
77
|
+
branch: worktree.branch,
|
|
78
|
+
worktreePath: worktree.path,
|
|
79
|
+
success: false,
|
|
80
|
+
result: null,
|
|
81
|
+
error: err.message,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { child, promise };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 输出所有任务的汇总信息
|
|
91
|
+
* @param {TaskSummary} summary - 汇总信息
|
|
92
|
+
*/
|
|
93
|
+
function printTaskSummary(summary: TaskSummary): void {
|
|
94
|
+
printDoubleSeparator();
|
|
95
|
+
printInfo(`全部任务已完成 (${summary.total}/${summary.total})`);
|
|
96
|
+
printInfo(` 成功: ${summary.succeeded}`);
|
|
97
|
+
printInfo(` 失败: ${summary.failed}`);
|
|
98
|
+
printInfo(` 总耗时: ${(summary.totalDurationMs / 1000).toFixed(1)}s`);
|
|
99
|
+
printInfo(` 总花费: $${summary.totalCostUsd.toFixed(2)}`);
|
|
100
|
+
printDoubleSeparator();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 处理用户中断(Ctrl+C)后的清理流程
|
|
105
|
+
* 根据全局配置决定自动清理或交互式确认
|
|
106
|
+
* @param {WorktreeInfo[]} worktrees - 本次创建的 worktree 列表
|
|
107
|
+
*/
|
|
108
|
+
async function handleInterruptCleanup(worktrees: WorktreeInfo[]): Promise<void> {
|
|
109
|
+
const autoDelete = getConfigValue('autoDeleteBranch');
|
|
110
|
+
|
|
111
|
+
if (autoDelete) {
|
|
112
|
+
// 全局配置了自动删除,直接清理
|
|
113
|
+
cleanupWorktrees(worktrees);
|
|
114
|
+
printSuccess(MESSAGES.INTERRUPT_AUTO_CLEANED(worktrees.length));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 交互式确认是否清理
|
|
119
|
+
const shouldClean = await confirmAction(MESSAGES.INTERRUPT_CONFIRM_CLEANUP);
|
|
120
|
+
|
|
121
|
+
if (shouldClean) {
|
|
122
|
+
cleanupWorktrees(worktrees);
|
|
123
|
+
printSuccess(MESSAGES.INTERRUPT_CLEANED(worktrees.length));
|
|
124
|
+
} else {
|
|
125
|
+
printInfo(MESSAGES.INTERRUPT_KEPT);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 更新进度面板中指定任务的完成/失败状态
|
|
131
|
+
* @param {ProgressRenderer} renderer - 进度面板渲染器
|
|
132
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
133
|
+
* @param {TaskResult} result - 任务执行结果
|
|
134
|
+
* @param {number} startTime - 任务批次启动时间戳
|
|
135
|
+
*/
|
|
136
|
+
function updateRendererStatus(renderer: ProgressRenderer, index: number, result: TaskResult, startTime: number): void {
|
|
137
|
+
if (result.success) {
|
|
138
|
+
renderer.markDone(
|
|
139
|
+
index,
|
|
140
|
+
result.result?.duration_ms ?? (Date.now() - startTime),
|
|
141
|
+
result.result?.total_cost_usd ?? 0,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
renderer.markFailed(
|
|
145
|
+
index,
|
|
146
|
+
result.result?.duration_ms ?? (Date.now() - startTime),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 以并发限制模式执行任务队列
|
|
153
|
+
* 维护活跃任务池,某个任务完成后立即启动队列中下一个
|
|
154
|
+
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
155
|
+
* @param {string[]} tasks - 任务描述列表
|
|
156
|
+
* @param {number} concurrency - 最大并发数
|
|
157
|
+
* @param {ProgressRenderer} renderer - 进度面板渲染器
|
|
158
|
+
* @param {number} startTime - 任务批次启动时间戳
|
|
159
|
+
* @param {() => boolean} isInterrupted - 检查是否已中断的函数
|
|
160
|
+
* @param {ChildProcess[]} childProcesses - 共享子进程数组,执行过程中动态追加
|
|
161
|
+
* @returns {Promise<TaskResult[]>} 所有任务结果
|
|
162
|
+
*/
|
|
163
|
+
async function executeWithConcurrency(
|
|
164
|
+
worktrees: WorktreeInfo[],
|
|
165
|
+
tasks: string[],
|
|
166
|
+
concurrency: number,
|
|
167
|
+
renderer: ProgressRenderer,
|
|
168
|
+
startTime: number,
|
|
169
|
+
isInterrupted: () => boolean,
|
|
170
|
+
childProcesses: ChildProcess[],
|
|
171
|
+
): Promise<TaskResult[]> {
|
|
172
|
+
const total = tasks.length;
|
|
173
|
+
const results: TaskResult[] = new Array(total);
|
|
174
|
+
let nextIndex = 0;
|
|
175
|
+
let completedCount = 0;
|
|
176
|
+
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
/**
|
|
179
|
+
* 启动下一个排队中的任务
|
|
180
|
+
* 从队列中取出任务并启动执行,完成时递归调用自身
|
|
181
|
+
*/
|
|
182
|
+
function launchNext(): void {
|
|
183
|
+
if (nextIndex >= total || isInterrupted()) return;
|
|
184
|
+
|
|
185
|
+
const index = nextIndex;
|
|
186
|
+
nextIndex++;
|
|
187
|
+
|
|
188
|
+
const wt = worktrees[index];
|
|
189
|
+
const task = tasks[index];
|
|
190
|
+
logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
|
|
191
|
+
|
|
192
|
+
// 标记为运行中
|
|
193
|
+
renderer.markRunning(index);
|
|
194
|
+
|
|
195
|
+
const handle = executeClaudeTask(wt, task);
|
|
196
|
+
childProcesses.push(handle.child);
|
|
197
|
+
|
|
198
|
+
// 监听 stderr 输出,更新任务活动时间戳
|
|
199
|
+
handle.child.stderr?.on('data', () => {
|
|
200
|
+
renderer.updateActivity(index);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
handle.promise.then((result) => {
|
|
204
|
+
results[index] = result;
|
|
205
|
+
completedCount++;
|
|
206
|
+
|
|
207
|
+
// 被中断时不再更新面板
|
|
208
|
+
if (!isInterrupted()) {
|
|
209
|
+
updateRendererStatus(renderer, index, result, startTime);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 启动下一个排队任务
|
|
213
|
+
launchNext();
|
|
214
|
+
|
|
215
|
+
// 所有任务完成时 resolve
|
|
216
|
+
if (completedCount === total) {
|
|
217
|
+
resolve(results);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 初始启动 concurrency 个任务
|
|
223
|
+
const initialBatch = Math.min(concurrency, total);
|
|
224
|
+
for (let i = 0; i < initialBatch; i++) {
|
|
225
|
+
launchNext();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 以全量并行模式执行所有任务
|
|
232
|
+
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
233
|
+
* @param {string[]} tasks - 任务描述列表
|
|
234
|
+
* @param {ProgressRenderer} renderer - 进度面板渲染器
|
|
235
|
+
* @param {number} startTime - 任务批次启动时间戳
|
|
236
|
+
* @param {() => boolean} isInterrupted - 检查是否已中断的函数
|
|
237
|
+
* @param {ChildProcess[]} childProcesses - 共享子进程数组,启动时追加
|
|
238
|
+
* @returns {Promise<TaskResult[]>} 所有任务结果
|
|
239
|
+
*/
|
|
240
|
+
async function executeAllParallel(
|
|
241
|
+
worktrees: WorktreeInfo[],
|
|
242
|
+
tasks: string[],
|
|
243
|
+
renderer: ProgressRenderer,
|
|
244
|
+
startTime: number,
|
|
245
|
+
isInterrupted: () => boolean,
|
|
246
|
+
childProcesses: ChildProcess[],
|
|
247
|
+
): Promise<TaskResult[]> {
|
|
248
|
+
const handles = worktrees.map((wt, index) => {
|
|
249
|
+
const task = tasks[index];
|
|
250
|
+
logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
|
|
251
|
+
const handle = executeClaudeTask(wt, task);
|
|
252
|
+
childProcesses.push(handle.child);
|
|
253
|
+
|
|
254
|
+
// 监听 stderr 输出,更新任务活动时间戳
|
|
255
|
+
handle.child.stderr?.on('data', () => {
|
|
256
|
+
renderer.updateActivity(index);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return handle;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const results = await Promise.all(
|
|
263
|
+
handles.map((handle, index) =>
|
|
264
|
+
handle.promise.then((result) => {
|
|
265
|
+
// 被中断时不再更新面板
|
|
266
|
+
if (!isInterrupted()) {
|
|
267
|
+
updateRendererStatus(renderer, index, result, startTime);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return results;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 批量任务执行的公共逻辑
|
|
279
|
+
* 负责进度面板、SIGINT 处理、并发控制、汇总输出
|
|
280
|
+
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
281
|
+
* @param {string[]} tasks - 任务描述列表
|
|
282
|
+
* @param {number} concurrency - 最大并发数,0 表示不限制
|
|
283
|
+
*/
|
|
284
|
+
export async function executeBatchTasks(
|
|
285
|
+
worktrees: WorktreeInfo[],
|
|
286
|
+
tasks: string[],
|
|
287
|
+
concurrency: number,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const count = tasks.length;
|
|
290
|
+
|
|
291
|
+
// 有并发限制时输出提示
|
|
292
|
+
if (concurrency > 0) {
|
|
293
|
+
printInfo(MESSAGES.CONCURRENCY_INFO(concurrency, count));
|
|
294
|
+
printInfo('');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 实例化进度面板渲染器
|
|
298
|
+
const startTime = Date.now();
|
|
299
|
+
const branches = worktrees.map((wt) => wt.branch);
|
|
300
|
+
const paths = worktrees.map((wt) => wt.path);
|
|
301
|
+
// 有并发限制时任务初始化为 pending 状态,否则初始化为 running
|
|
302
|
+
const allRunning = concurrency === 0;
|
|
303
|
+
const renderer = new ProgressRenderer(branches, paths, allRunning);
|
|
304
|
+
|
|
305
|
+
// 启动进度面板渲染
|
|
306
|
+
renderer.start();
|
|
307
|
+
|
|
308
|
+
// 共享中断状态标志和子进程引用数组
|
|
309
|
+
let interrupted = false;
|
|
310
|
+
const isInterrupted = () => interrupted;
|
|
311
|
+
const childProcesses: ChildProcess[] = [];
|
|
312
|
+
|
|
313
|
+
// 监听 SIGINT(Ctrl+C),终止所有子进程并触发清理流程
|
|
314
|
+
const sigintHandler = async () => {
|
|
315
|
+
if (interrupted) return;
|
|
316
|
+
interrupted = true;
|
|
317
|
+
|
|
318
|
+
// 停止进度面板渲染
|
|
319
|
+
renderer.stop();
|
|
320
|
+
|
|
321
|
+
printInfo('');
|
|
322
|
+
printWarning(MESSAGES.INTERRUPTED);
|
|
323
|
+
killAllChildProcesses(childProcesses);
|
|
324
|
+
|
|
325
|
+
// 等待所有已启动的子进程退出后再执行清理
|
|
326
|
+
await Promise.allSettled(childProcesses.map((cp) =>
|
|
327
|
+
new Promise<void>((resolve) => {
|
|
328
|
+
if (cp.exitCode !== null) {
|
|
329
|
+
resolve();
|
|
330
|
+
} else {
|
|
331
|
+
cp.on('close', () => resolve());
|
|
332
|
+
}
|
|
333
|
+
}),
|
|
334
|
+
));
|
|
335
|
+
|
|
336
|
+
await handleInterruptCleanup(worktrees);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
};
|
|
339
|
+
process.on('SIGINT', sigintHandler);
|
|
340
|
+
|
|
341
|
+
// 根据并发限制选择执行模式
|
|
342
|
+
const results = concurrency > 0
|
|
343
|
+
? await executeWithConcurrency(worktrees, tasks, concurrency, renderer, startTime, isInterrupted, childProcesses)
|
|
344
|
+
: await executeAllParallel(worktrees, tasks, renderer, startTime, isInterrupted, childProcesses);
|
|
345
|
+
|
|
346
|
+
// 正常完成,停止进度面板并移除 SIGINT 监听器
|
|
347
|
+
renderer.stop();
|
|
348
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
349
|
+
|
|
350
|
+
// 被中断时不输出汇总(已在 sigintHandler 中处理退出)
|
|
351
|
+
if (interrupted) return;
|
|
352
|
+
|
|
353
|
+
const totalDurationMs = Date.now() - startTime;
|
|
354
|
+
|
|
355
|
+
// 汇总
|
|
356
|
+
const summary: TaskSummary = {
|
|
357
|
+
total: results.length,
|
|
358
|
+
succeeded: results.filter((r) => r.success).length,
|
|
359
|
+
failed: results.filter((r) => !r.success).length,
|
|
360
|
+
totalDurationMs,
|
|
361
|
+
totalCostUsd: results.reduce((sum, r) => sum + (r.result?.total_cost_usd ?? 0), 0),
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
printTaskSummary(summary);
|
|
365
|
+
}
|