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.
@@ -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
- * 在指定 worktree 中执行 Claude Code 任务,由于是--output-format json形式,所以这里固定claude code cli的启动命令
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
- stdout += data.toString();
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
- 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;
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
- } catch {
61
- logger.warn(`解析 Claude Code 输出失败: ${stdout.substring(0, 200)}`);
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
- childProcesses.push(handle.child);
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
- childProcesses.push(handle.child);
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
  });