clawt 2.16.4 → 2.17.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/README.md +14 -0
- package/dist/index.js +531 -71
- package/dist/postinstall.js +26 -3
- package/docs/spec.md +108 -5
- package/package.json +2 -1
- package/src/commands/completion.ts +98 -0
- package/src/constants/index.ts +12 -2
- package/src/constants/messages/completion.ts +23 -0
- package/src/constants/messages/index.ts +2 -0
- package/src/constants/messages/run.ts +4 -4
- package/src/constants/progress.ts +36 -6
- package/src/index.ts +2 -0
- package/src/utils/completion-engine.ts +174 -0
- package/src/utils/completion-scripts.ts +58 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/progress-render.ts +77 -13
- package/src/utils/progress.ts +110 -24
- package/src/utils/stream-parser.ts +251 -0
- package/src/utils/task-executor.ts +61 -27
- package/tests/unit/commands/completion.test.ts +1116 -0
- package/tests/unit/utils/progress-render.test.ts +96 -0
- package/tests/unit/utils/progress.test.ts +391 -10
- package/tests/unit/utils/stream-parser.test.ts +375 -0
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +0 -125
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { ClaudeCodeResult } from '../types/index.js';
|
|
3
|
+
import {
|
|
4
|
+
ACTIVITY_TEXT_MAX_LENGTH,
|
|
5
|
+
TEXT_ACTIVITY_PREFIX,
|
|
6
|
+
} from '../constants/index.js';
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// 类型定义
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
/** stream-json content 数组中的单个块 */
|
|
13
|
+
interface StreamContentBlock {
|
|
14
|
+
/** 内容类型:tool_use / text / tool_result */
|
|
15
|
+
type: string;
|
|
16
|
+
/** 工具名称(tool_use 时) */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** 工具调用参数(tool_use 时) */
|
|
19
|
+
input?: Record<string, unknown>;
|
|
20
|
+
/** 文本内容(text 时) */
|
|
21
|
+
text?: string;
|
|
22
|
+
/** 工具执行结果内容(tool_result 时) */
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** stream-json 单行事件中 assistant/user 消息的 message 结构 */
|
|
27
|
+
interface StreamMessage {
|
|
28
|
+
content?: StreamContentBlock[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** stream-json 单行事件的统一结构 */
|
|
32
|
+
export interface StreamEvent {
|
|
33
|
+
/** 事件类型:system / assistant / user / result */
|
|
34
|
+
type: string;
|
|
35
|
+
/** 事件子类型 */
|
|
36
|
+
subtype?: string;
|
|
37
|
+
/** assistant/user 消息体 */
|
|
38
|
+
message?: StreamMessage;
|
|
39
|
+
/** user 消息的 content(tool_result 场景) */
|
|
40
|
+
content?: StreamContentBlock[];
|
|
41
|
+
// --- result 类型特有字段 ---
|
|
42
|
+
/** 是否为错误结果 */
|
|
43
|
+
is_error?: boolean;
|
|
44
|
+
/** 总耗时(毫秒) */
|
|
45
|
+
duration_ms?: number;
|
|
46
|
+
/** API 调用耗时(毫秒) */
|
|
47
|
+
duration_api_ms?: number;
|
|
48
|
+
/** 交互轮次数 */
|
|
49
|
+
num_turns?: number;
|
|
50
|
+
/** 会话结果文本 */
|
|
51
|
+
result?: string;
|
|
52
|
+
/** 停止原因 */
|
|
53
|
+
stop_reason?: string;
|
|
54
|
+
/** 会话 ID */
|
|
55
|
+
session_id?: string;
|
|
56
|
+
/** 总费用(美元) */
|
|
57
|
+
total_cost_usd?: number;
|
|
58
|
+
/** 使用量统计 */
|
|
59
|
+
usage?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 解析出的活动信息 */
|
|
63
|
+
export interface ParsedActivity {
|
|
64
|
+
/** 活动类型 */
|
|
65
|
+
kind: 'tool_use' | 'text' | 'result';
|
|
66
|
+
/** 格式化后的活动描述文本(已截断到 ACTIVITY_TEXT_MAX_LENGTH) */
|
|
67
|
+
activityText: string;
|
|
68
|
+
/** 最终结果对象(仅 kind=result 时有值) */
|
|
69
|
+
result?: ClaudeCodeResult;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 行缓冲器,处理 data 事件中的不完整行 */
|
|
73
|
+
export interface LineBuffer {
|
|
74
|
+
/** 追加数据并返回完整的行 */
|
|
75
|
+
push(chunk: string): string[];
|
|
76
|
+
/** 获取缓冲区中剩余的不完整数据(流结束时调用) */
|
|
77
|
+
flush(): string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================
|
|
81
|
+
// 核心函数
|
|
82
|
+
// ============================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 创建行缓冲器,用于处理流式数据中的不完整行
|
|
86
|
+
* stdout 的 data 事件可能在行中间切割,需要缓冲直到遇到换行符
|
|
87
|
+
* @returns {LineBuffer} 行缓冲器实例
|
|
88
|
+
*/
|
|
89
|
+
export function createLineBuffer(): LineBuffer {
|
|
90
|
+
let buffer = '';
|
|
91
|
+
return {
|
|
92
|
+
push(chunk: string): string[] {
|
|
93
|
+
buffer += chunk;
|
|
94
|
+
const lines = buffer.split('\n');
|
|
95
|
+
// 最后一个元素可能是不完整的行,保留在缓冲区
|
|
96
|
+
buffer = lines.pop() ?? '';
|
|
97
|
+
return lines;
|
|
98
|
+
},
|
|
99
|
+
flush(): string | null {
|
|
100
|
+
const remaining = buffer;
|
|
101
|
+
buffer = '';
|
|
102
|
+
return remaining || null;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 解析单行 stream-json 输出为事件对象
|
|
109
|
+
* 忽略空行和非 JSON 行
|
|
110
|
+
* @param {string} line - 单行文本
|
|
111
|
+
* @returns {StreamEvent | null} 解析成功返回事件对象,失败返回 null
|
|
112
|
+
*/
|
|
113
|
+
export function parseStreamLine(line: string): StreamEvent | null {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) return null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(trimmed) as StreamEvent;
|
|
119
|
+
} catch {
|
|
120
|
+
// 非 JSON 行(verbose 模式可能有额外日志),静默忽略
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 将文本截断到指定最大长度,超长部分用省略号替代
|
|
127
|
+
* @param {string} text - 原始文本
|
|
128
|
+
* @param {number} maxLength - 最大字符数
|
|
129
|
+
* @returns {string} 截断后的文本
|
|
130
|
+
*/
|
|
131
|
+
export function truncateText(text: string, maxLength: number): string {
|
|
132
|
+
if (text.length <= maxLength) return text;
|
|
133
|
+
return text.substring(0, maxLength - 1) + '…';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 根据活动类型和参数生成活动描述文本
|
|
138
|
+
* tool_use 类型直接使用英文工具名,有 file_path 参数时追加文件名
|
|
139
|
+
* 输出最大长度为 ACTIVITY_TEXT_MAX_LENGTH(30 字符),超出截断并追加省略号
|
|
140
|
+
* @param {'tool_use' | 'text'} kind - 活动类型
|
|
141
|
+
* @param {string} [toolName] - 工具名(tool_use 时)
|
|
142
|
+
* @param {Record<string, unknown>} [input] - 工具参数(tool_use 时)
|
|
143
|
+
* @param {string} [text] - 文本内容(text 时)
|
|
144
|
+
* @returns {string} 格式化后的活动描述文本
|
|
145
|
+
*/
|
|
146
|
+
export function formatActivityText(
|
|
147
|
+
kind: 'tool_use' | 'text',
|
|
148
|
+
toolName?: string,
|
|
149
|
+
input?: Record<string, unknown>,
|
|
150
|
+
text?: string,
|
|
151
|
+
): string {
|
|
152
|
+
let raw = '';
|
|
153
|
+
|
|
154
|
+
if (kind === 'tool_use' && toolName) {
|
|
155
|
+
// 对有文件路径参数的工具,追加文件名
|
|
156
|
+
const filePath = input?.file_path as string | undefined;
|
|
157
|
+
// 对有 command 参数的工具(如 Bash),追加命令内容
|
|
158
|
+
const command = input?.command as string | undefined;
|
|
159
|
+
if (filePath) {
|
|
160
|
+
const basename = path.basename(filePath);
|
|
161
|
+
raw = `${toolName} ${basename}`;
|
|
162
|
+
} else if (command) {
|
|
163
|
+
// 清理命令中的换行和多余空白
|
|
164
|
+
const cleaned = command.replace(/[\n\r\t]+/g, ' ').trim();
|
|
165
|
+
raw = `${toolName} ${cleaned}`;
|
|
166
|
+
} else {
|
|
167
|
+
raw = toolName;
|
|
168
|
+
}
|
|
169
|
+
} else if (kind === 'text' && text) {
|
|
170
|
+
// 清理控制字符和换行
|
|
171
|
+
const cleaned = text.replace(/[\n\r\t]/g, ' ').trim();
|
|
172
|
+
if (!cleaned) return '';
|
|
173
|
+
raw = `${TEXT_ACTIVITY_PREFIX}: ${cleaned}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!raw) return '';
|
|
177
|
+
|
|
178
|
+
return truncateText(raw, ACTIVITY_TEXT_MAX_LENGTH);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 从流式事件中提取活动信息
|
|
183
|
+
* 仅处理以下有意义的事件:
|
|
184
|
+
* 1. type=assistant + content 含 tool_use → 提取工具名和参数
|
|
185
|
+
* 2. type=assistant + content 含 text → 提取文本片段
|
|
186
|
+
* 3. type=result → 最终结果,构造 ClaudeCodeResult
|
|
187
|
+
* @param {StreamEvent} event - 流式事件对象
|
|
188
|
+
* @returns {ParsedActivity | null} 提取的活动信息,无关事件返回 null
|
|
189
|
+
*/
|
|
190
|
+
export function parseStreamEvent(event: StreamEvent): ParsedActivity | null {
|
|
191
|
+
// 最终结果事件
|
|
192
|
+
if (event.type === 'result') {
|
|
193
|
+
const result: ClaudeCodeResult = {
|
|
194
|
+
type: event.type,
|
|
195
|
+
subtype: event.subtype ?? '',
|
|
196
|
+
is_error: event.is_error ?? false,
|
|
197
|
+
duration_ms: event.duration_ms ?? 0,
|
|
198
|
+
duration_api_ms: event.duration_api_ms ?? 0,
|
|
199
|
+
num_turns: event.num_turns ?? 0,
|
|
200
|
+
result: event.result ?? '',
|
|
201
|
+
stop_reason: event.stop_reason ?? '',
|
|
202
|
+
session_id: event.session_id ?? '',
|
|
203
|
+
total_cost_usd: event.total_cost_usd ?? 0,
|
|
204
|
+
usage: event.usage ?? {},
|
|
205
|
+
};
|
|
206
|
+
return { kind: 'result', activityText: '', result };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// assistant 消息:从 message.content 中提取
|
|
210
|
+
if (event.type === 'assistant') {
|
|
211
|
+
const content = event.message?.content;
|
|
212
|
+
if (!content || content.length === 0) return null;
|
|
213
|
+
|
|
214
|
+
// 优先查找 tool_use(取最后一个,代表最新操作)
|
|
215
|
+
let lastToolUse: StreamContentBlock | null = null;
|
|
216
|
+
let lastText: StreamContentBlock | null = null;
|
|
217
|
+
|
|
218
|
+
for (const block of content) {
|
|
219
|
+
if (block.type === 'tool_use') {
|
|
220
|
+
lastToolUse = block;
|
|
221
|
+
} else if (block.type === 'text') {
|
|
222
|
+
lastText = block;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// tool_use 优先级高于 text
|
|
227
|
+
if (lastToolUse) {
|
|
228
|
+
const activityText = formatActivityText(
|
|
229
|
+
'tool_use',
|
|
230
|
+
lastToolUse.name,
|
|
231
|
+
lastToolUse.input,
|
|
232
|
+
);
|
|
233
|
+
if (activityText) {
|
|
234
|
+
return { kind: 'tool_use', activityText };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (lastText?.text) {
|
|
239
|
+
const activityText = formatActivityText('text', undefined, undefined, lastText.text);
|
|
240
|
+
if (activityText) {
|
|
241
|
+
return { kind: 'text', activityText };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// user 消息中的 tool_result 不更新活动文本(紧接着会有下一个 assistant 消息)
|
|
249
|
+
// 其他类型(system 等)忽略
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
@@ -7,6 +7,8 @@ import { cleanupWorktrees } from './worktree.js';
|
|
|
7
7
|
import { getConfigValue } from './config.js';
|
|
8
8
|
import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
|
|
9
9
|
import { ProgressRenderer } from './progress.js';
|
|
10
|
+
import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
|
|
11
|
+
import { RESULT_PREVIEW_MAX_LENGTH } from '../constants/index.js';
|
|
10
12
|
|
|
11
13
|
/** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
|
|
12
14
|
interface ClaudeTaskHandle {
|
|
@@ -17,15 +19,23 @@ interface ClaudeTaskHandle {
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
*
|
|
22
|
+
* 活动更新回调函数类型
|
|
23
|
+
* @param {string} activityText - 活动描述文本
|
|
24
|
+
*/
|
|
25
|
+
type ActivityCallback = (activityText: string) => void;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 在指定 worktree 中执行 Claude Code 任务,使用 stream-json 格式获取实时事件
|
|
21
29
|
* @param {WorktreeInfo} worktree - worktree 信息
|
|
22
30
|
* @param {string} task - 任务描述
|
|
31
|
+
* @param {ActivityCallback} [onActivity] - 活动更新回调(可选)
|
|
23
32
|
* @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
|
|
24
33
|
*/
|
|
25
|
-
function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHandle {
|
|
34
|
+
function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback): ClaudeTaskHandle {
|
|
35
|
+
// 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
|
|
26
36
|
const child = spawnProcess(
|
|
27
37
|
'claude',
|
|
28
|
-
['-p', task, '--output-format', 'json', '--permission-mode', 'bypassPermissions'],
|
|
38
|
+
['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'],
|
|
29
39
|
{
|
|
30
40
|
cwd: worktree.path,
|
|
31
41
|
// stdin 必须设置为 'ignore',不能用 'pipe'
|
|
@@ -37,11 +47,27 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
|
|
|
37
47
|
);
|
|
38
48
|
|
|
39
49
|
const promise = new Promise<TaskResult>((resolve) => {
|
|
40
|
-
let stdout = '';
|
|
41
50
|
let stderr = '';
|
|
51
|
+
let finalResult: ClaudeCodeResult | null = null;
|
|
52
|
+
const lineBuffer = createLineBuffer();
|
|
42
53
|
|
|
43
54
|
child.stdout?.on('data', (data: Buffer) => {
|
|
44
|
-
|
|
55
|
+
const lines = lineBuffer.push(data.toString());
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const event = parseStreamLine(line);
|
|
58
|
+
if (!event) continue;
|
|
59
|
+
|
|
60
|
+
const parsed = parseStreamEvent(event);
|
|
61
|
+
if (!parsed) continue;
|
|
62
|
+
|
|
63
|
+
if (parsed.kind === 'result') {
|
|
64
|
+
// 最终结果事件
|
|
65
|
+
finalResult = parsed.result ?? null;
|
|
66
|
+
} else if (parsed.activityText && onActivity) {
|
|
67
|
+
// 活动事件,回调通知
|
|
68
|
+
onActivity(parsed.activityText);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
45
71
|
});
|
|
46
72
|
|
|
47
73
|
child.stderr?.on('data', (data: Buffer) => {
|
|
@@ -49,16 +75,21 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
|
|
|
49
75
|
});
|
|
50
76
|
|
|
51
77
|
child.on('close', (code) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
// 处理缓冲区中可能残留的最后一行
|
|
79
|
+
const remaining = lineBuffer.flush();
|
|
80
|
+
if (remaining) {
|
|
81
|
+
const event = parseStreamLine(remaining);
|
|
82
|
+
if (event) {
|
|
83
|
+
const parsed = parseStreamEvent(event);
|
|
84
|
+
if (parsed?.kind === 'result') {
|
|
85
|
+
finalResult = parsed.result ?? null;
|
|
86
|
+
}
|
|
59
87
|
}
|
|
60
|
-
}
|
|
61
|
-
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let success = code === 0;
|
|
91
|
+
if (finalResult) {
|
|
92
|
+
success = !finalResult.is_error;
|
|
62
93
|
}
|
|
63
94
|
|
|
64
95
|
resolve({
|
|
@@ -66,7 +97,7 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
|
|
|
66
97
|
branch: worktree.branch,
|
|
67
98
|
worktreePath: worktree.path,
|
|
68
99
|
success,
|
|
69
|
-
result,
|
|
100
|
+
result: finalResult,
|
|
70
101
|
error: success ? undefined : stderr || '任务执行失败',
|
|
71
102
|
});
|
|
72
103
|
});
|
|
@@ -134,16 +165,25 @@ async function handleInterruptCleanup(worktrees: WorktreeInfo[]): Promise<void>
|
|
|
134
165
|
* @param {number} startTime - 任务批次启动时间戳
|
|
135
166
|
*/
|
|
136
167
|
function updateRendererStatus(renderer: ProgressRenderer, index: number, result: TaskResult, startTime: number): void {
|
|
168
|
+
// 从 ClaudeCodeResult.result 中提取结果预览文本
|
|
169
|
+
// 先清理换行符和多余空白(结果文本可能包含多行),再截断到最大长度
|
|
170
|
+
const rawResultText = result.result?.result;
|
|
171
|
+
const resultPreview = rawResultText
|
|
172
|
+
? truncateText(rawResultText.replace(/[\n\r\t]+/g, ' ').trim(), RESULT_PREVIEW_MAX_LENGTH)
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
137
175
|
if (result.success) {
|
|
138
176
|
renderer.markDone(
|
|
139
177
|
index,
|
|
140
178
|
result.result?.duration_ms ?? (Date.now() - startTime),
|
|
141
179
|
result.result?.total_cost_usd ?? 0,
|
|
180
|
+
resultPreview,
|
|
142
181
|
);
|
|
143
182
|
} else {
|
|
144
183
|
renderer.markFailed(
|
|
145
184
|
index,
|
|
146
185
|
result.result?.duration_ms ?? (Date.now() - startTime),
|
|
186
|
+
resultPreview,
|
|
147
187
|
);
|
|
148
188
|
}
|
|
149
189
|
}
|
|
@@ -192,13 +232,10 @@ async function executeWithConcurrency(
|
|
|
192
232
|
// 标记为运行中
|
|
193
233
|
renderer.markRunning(index);
|
|
194
234
|
|
|
195
|
-
const handle = executeClaudeTask(wt, task)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// 监听 stderr 输出,更新任务活动时间戳
|
|
199
|
-
handle.child.stderr?.on('data', () => {
|
|
200
|
-
renderer.updateActivity(index);
|
|
235
|
+
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
236
|
+
renderer.updateActivityText(index, activityText);
|
|
201
237
|
});
|
|
238
|
+
childProcesses.push(handle.child);
|
|
202
239
|
|
|
203
240
|
handle.promise.then((result) => {
|
|
204
241
|
results[index] = result;
|
|
@@ -248,13 +285,10 @@ async function executeAllParallel(
|
|
|
248
285
|
const handles = worktrees.map((wt, index) => {
|
|
249
286
|
const task = tasks[index];
|
|
250
287
|
logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
|
|
251
|
-
const handle = executeClaudeTask(wt, task)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// 监听 stderr 输出,更新任务活动时间戳
|
|
255
|
-
handle.child.stderr?.on('data', () => {
|
|
256
|
-
renderer.updateActivity(index);
|
|
288
|
+
const handle = executeClaudeTask(wt, task, (activityText) => {
|
|
289
|
+
renderer.updateActivityText(index, activityText);
|
|
257
290
|
});
|
|
291
|
+
childProcesses.push(handle.child);
|
|
258
292
|
|
|
259
293
|
return handle;
|
|
260
294
|
});
|