claude-coder 1.4.0 → 1.5.1

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 CHANGED
@@ -98,7 +98,8 @@ your-project/
98
98
  tests.json # 验证记录
99
99
  test.env # 测试凭证(API Key 等,可选)
100
100
  playwright-auth.json # Playwright 登录状态(可选,auth 命令生成)
101
- .runtime/ # 临时文件(含日志)
101
+ .runtime/ # 临时文件
102
+ logs/ # 每 session 独立日志(含工具调用记录)
102
103
  requirements.md # 需求文档(可选)
103
104
  ```
104
105
 
@@ -108,6 +109,8 @@ your-project/
108
109
 
109
110
  **中断恢复**:直接重新运行 `claude-coder run`,会从上次中断处继续。
110
111
 
112
+ **长时间无响应**:模型处理复杂文件时可能出现 10-20 分钟的思考间隔(spinner 会显示红色警告),这是正常行为。超过 30 分钟无工具调用时 Harness 会自动中断并重试。可通过 `.env` 中 `SESSION_STALL_TIMEOUT=秒数` 调整阈值。
113
+
111
114
  **跳过任务**:将 `.claude-coder/tasks.json` 中该任务的 `status` 改为 `done`。
112
115
 
113
116
  **Windows 支持**:完全支持,纯 Node.js 实现。
@@ -21,9 +21,12 @@ Agent 在单次 session 中应最大化推进任务进度。**任何非致命问
21
21
  - 缺少 API Key → 用 mock 或代码逻辑验证替代,记录到 `test.env`,继续推进
22
22
  - 测试环境未就绪 → 跳过该测试步骤,完成其余可验证的步骤
23
23
  - 服务启动失败 → 尝试排查修复,无法修复则记录问题后推进代码实现
24
+ - **长时间思考是正常行为**:模型处理大文件(如 500+ 行的代码文件)时可能出现 10-20 分钟的思考间隔,不代表卡死
24
25
 
25
26
  **反面案例**:Agent 因 `OPENAI_API_KEY` 缺失直接标记任务 `failed` → 浪费整个 session
26
27
 
28
+ > Harness 兜底机制:当工具调用间隔超过 `SESSION_STALL_TIMEOUT`(默认 30 分钟)时自动中断 session 并触发 rollback + 重试。此阈值设计为远超正常思考时长,仅捕捉真正的卡死场景。
29
+
27
30
  ### 规则 2:回滚即彻底回滚
28
31
 
29
32
  `git reset --hard` 是全量回滚,不做部分文件保护。
@@ -56,6 +59,16 @@ Agent 在单次 session 中应最大化推进任务进度。**任何非致命问
56
59
 
57
60
  Agent 不应浪费工具调用读取 harness 已知的数据。所有可预读的上下文通过 prompt hint 注入(见第 5 节 Prompt 注入架构)。
58
61
 
62
+ ### 规则 6:停顿检测 — 模型卡死自动恢复
63
+
64
+ 模型可能长时间「思考」但不调用工具(20+ 分钟无进展)。Harness 通过 PreToolUse hook 追踪最后一次工具调用时间:
65
+
66
+ - 无工具调用 > `SESSION_STALL_TIMEOUT`(默认 1800 秒 / 30 分钟) → 自动中断 session
67
+ - 中断后进入 runner 的重试逻辑(连续失败 ≥ 3 次 → 标记 task failed)
68
+ - Spinner 在无工具调用 > 2 分钟时显示红色警告
69
+
70
+ 配置方式:`.claude-coder/.env` 中设置 `SESSION_STALL_TIMEOUT=1800`(秒)
71
+
59
72
  ---
60
73
 
61
74
  ## 1. 核心架构
@@ -79,7 +92,7 @@ flowchart TB
79
92
  direction TB
80
93
  profile["project_profile.json<br/>tasks.json"]
81
94
  runtime["session_result.json<br/>progress.json"]
82
- phase[".runtime/<br/>phase / step / activity.log"]
95
+ phase[".runtime/<br/>phase / step / logs/"]
83
96
  end
84
97
 
85
98
  scan -->|"systemPrompt =<br/>CLAUDE.md + SCAN_PROTOCOL.md"| query
@@ -96,7 +109,8 @@ flowchart TB
96
109
  **核心特征:**
97
110
  - **项目无关**:项目信息由 Agent 扫描后存入 `project_profile.json`,harness 不含项目特定逻辑
98
111
  - **可恢复**:通过 `session_result.json` 跨会话记忆,任意 session 可断点续跑
99
- - **可观测**:SDK 内联 `PreToolUse` hook 实时显示 Agent 当前步骤和工具调用
112
+ - **可观测**:SDK 内联 `PreToolUse` hook 实时显示当前工具、操作目标和停顿警告
113
+ - **自愈**:编辑死循环检测 + 停顿超时自动中断 + runner 重试机制
100
114
  - **跨平台**:纯 Node.js 实现,macOS / Linux / Windows 通用
101
115
  - **零依赖**:`dependencies` 为空,Claude Agent SDK 作为 peerDependency
102
116
 
@@ -144,14 +158,14 @@ bin/cli.js CLI 入口:参数解析、命令路由、SDK peerDep 检
144
158
  src/
145
159
  config.js 配置管理:.env 加载、模型映射、环境变量构建、全局同步
146
160
  runner.js 主循环:scan → session → validate → retry/rollback
147
- session.js SDK 交互:query() 调用、hook 绑定、日志流
161
+ session.js SDK 交互:query() 调用、hook 绑定、停顿检测、日志流
148
162
  prompts.js 提示语构建:系统 prompt 组合 + 条件 hint + 任务分解指导
149
163
  init.js 环境初始化:读取 profile 执行依赖安装、服务启动、健康检查
150
164
  scanner.js 初始化扫描:调用 runScanSession + 重试
151
165
  validator.js 校验引擎:分层校验(fatal/recoverable/pass)+ git 检查 + 测试覆盖
152
166
  tasks.js 任务管理:CRUD + 状态机 + 进度展示
153
167
  auth.js Playwright 凭证:导出登录状态 + MCP 配置 + gitignore
154
- indicator.js 进度指示:终端 spinner + phase/step 文件写入
168
+ indicator.js 进度指示:终端 spinner + 工具目标显示 + 停顿警告 + phase/step 文件写入
155
169
  setup.js 交互式配置:模型选择、API Key、MCP 工具
156
170
  templates/
157
171
  CLAUDE.md Agent 协议(注入为 systemPrompt)
@@ -193,7 +207,7 @@ templates/
193
207
  | `session_result.json` | 每次 session 结束 | 当前 session 结果(扁平格式,向后兼容旧 `current` 包装) |
194
208
  | `playwright-auth.json` | `claude-coder auth` | Playwright 登录状态(cookies + localStorage) |
195
209
  | `tests.json` | 首次测试时 | 验证记录(防止反复测试) |
196
- | `.runtime/` | 运行时 | 临时文件(phase、step、activity.log、logs/) |
210
+ | `.runtime/` | 运行时 | 临时文件(phase、step、logs/);工具调用记录合并到 session log |
197
211
 
198
212
  ---
199
213
 
@@ -299,17 +313,27 @@ sequenceDiagram
299
313
  participant SDK as Claude Agent SDK
300
314
  participant Hook as inferPhaseStep()
301
315
  participant Ind as Indicator (setInterval)
316
+ participant Stall as stallChecker (30s)
302
317
  participant Term as 终端
303
318
 
304
319
  SDK->>Hook: PreToolUse 回调<br/>{tool_name: "Edit", tool_input: {path: "src/app.tsx"}}
305
320
  Hook->>Hook: 推断阶段: Edit → coding
306
321
  Hook->>Ind: updatePhase("coding")
322
+ Hook->>Ind: lastToolTime = now
323
+ Hook->>Ind: toolTarget = "src/app.tsx"
307
324
  Hook->>Ind: appendActivity("Edit", "src/app.tsx")
308
325
 
309
326
  Note over SDK,Hook: 同步回调,return {decision: "allow"}
310
327
 
311
328
  loop 每 500ms
312
- Ind->>Term: ⠋ [Session 3] 编码中 02:15 | Git 操作
329
+ Ind->>Term: ⠋ [Session 3] 编码中 02:15 | 读取文件: ppt_generator.py
330
+ end
331
+
332
+ loop 每 30s
333
+ Stall->>Ind: 检查 now - lastToolTime
334
+ alt 超过 STALL_TIMEOUT
335
+ Stall->>SDK: stallDetected = true → break for-await
336
+ end
313
337
  end
314
338
  ```
315
339
 
@@ -364,9 +388,12 @@ Harness 在 `buildCodingPrompt()` 中预读 `tasks.json`,将下一个待办任
364
388
 
365
389
  Harness 在 `buildCodingPrompt()` 中预读 `session_result.json`,将上次会话的 task_id、结果和 notes 摘要注入 user prompt。Agent 无需自行读取历史 session 数据。
366
390
 
367
- ### Loop Detection(编辑死循环检测)
391
+ ### 自愈机制
392
+
393
+ **编辑死循环检测**:PreToolUse hook 追踪每个文件的编辑次数,同一文件 Write/Edit 超 5 次 → `decision: "block"`。
368
394
 
369
- PreToolUse hook 中追踪每个文件的编辑次数。当同一文件被 Write/Edit 超过 5 次时,hook 返回 `decision: "block"` 阻止操作并提示 Agent 重新审视方案。
395
+ **停顿超时检测**:每 30 秒检查 `indicator.lastToolTime`,若距上次工具调用超过 `SESSION_STALL_TIMEOUT`(默认 1800 / 30 分钟),自动 `break` 退出并触发 rollback + 重试。
396
+ > 注意:模型在处理复杂文件时可能出现 10-20 分钟的长时间思考,这是正常行为。超时设为 30 分钟以避免误杀正常思考。可通过 `.env` 中 `SESSION_STALL_TIMEOUT=秒数` 自定义。
370
397
 
371
398
  ### 文件权限模型
372
399
 
@@ -423,7 +450,7 @@ query({
423
450
  ### V2 迁移条件(等待稳定后)
424
451
 
425
452
  1. V2 去掉 `unstable_` 前缀,正式发布
426
- 2. V2 支持 Hooks(当前项目依赖 PreToolUse 做 spinner 和 activity log)
453
+ 2. V2 支持 Hooks(当前项目依赖 PreToolUse 做 spinner 和日志记录)
427
454
  3. V2 支持 Subagents(未来可能用于扫描 Agent / 编码 Agent 分离)
428
455
 
429
456
  ### 可利用但尚未使用的 V1 特性
package/docs/README.en.md CHANGED
@@ -86,7 +86,8 @@ your-project/
86
86
  tests.json # Verification records
87
87
  test.env # Test credentials (API keys, optional)
88
88
  playwright-auth.json # Playwright login state (optional, via auth command)
89
- .runtime/ # Temp files (logs)
89
+ .runtime/ # Temp files
90
+ logs/ # Per-session logs (with tool call traces)
90
91
  requirements.md # Requirements (optional)
91
92
  ```
92
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-coder",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Claude Coder — Autonomous coding agent harness powered by Claude Code SDK. Scan, plan, code, validate, git-commit in a loop.",
5
5
  "bin": {
6
6
  "claude-coder": "bin/cli.js"
package/src/config.js CHANGED
@@ -63,7 +63,6 @@ function paths() {
63
63
  runtime,
64
64
  phaseFile: path.join(runtime, 'phase'),
65
65
  stepFile: path.join(runtime, 'step'),
66
- activityLog: path.join(runtime, 'activity.log'),
67
66
  logsDir: path.join(runtime, 'logs'),
68
67
  };
69
68
  }
@@ -107,6 +106,7 @@ function loadConfig() {
107
106
  defaultSonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || '',
108
107
  defaultHaiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
109
108
  thinkingBudget: env.ANTHROPIC_THINKING_BUDGET || '',
109
+ stallTimeout: parseInt(env.SESSION_STALL_TIMEOUT, 10) || 1800,
110
110
  raw: env,
111
111
  };
112
112
 
package/src/indicator.js CHANGED
@@ -9,9 +9,11 @@ class Indicator {
9
9
  constructor() {
10
10
  this.phase = 'thinking';
11
11
  this.step = '';
12
+ this.toolTarget = '';
12
13
  this.spinnerIndex = 0;
13
14
  this.timer = null;
14
15
  this.lastActivity = '';
16
+ this.lastToolTime = Date.now();
15
17
  this.sessionNum = 0;
16
18
  this.startTime = Date.now();
17
19
  }
@@ -41,13 +43,7 @@ class Indicator {
41
43
  }
42
44
 
43
45
  appendActivity(toolName, summary) {
44
- const ts = new Date().toISOString();
45
- const entry = `[${ts}] ${toolName}: ${summary}`;
46
- this.lastActivity = entry;
47
- try {
48
- const p = paths();
49
- fs.appendFileSync(p.activityLog, entry + '\n', 'utf8');
50
- } catch { /* ignore */ }
46
+ this.lastActivity = `${toolName}: ${summary}`;
51
47
  }
52
48
 
53
49
  _writePhaseFile() {
@@ -74,8 +70,17 @@ class Indicator {
74
70
  ? `${COLOR.yellow}思考中${COLOR.reset}`
75
71
  : `${COLOR.green}编码中${COLOR.reset}`;
76
72
 
73
+ const idleMs = Date.now() - this.lastToolTime;
74
+ const idleMin = Math.floor(idleMs / 60000);
75
+
77
76
  let line = `${spinner} [Session ${this.sessionNum}] ${clock} ${phaseLabel} ${mm}:${ss}`;
78
- if (this.step) line += ` | ${this.step}`;
77
+ if (idleMin >= 2) {
78
+ line += ` | ${COLOR.red}${idleMin}分无工具调用${COLOR.reset}`;
79
+ }
80
+ if (this.step) {
81
+ line += ` | ${this.step}`;
82
+ if (this.toolTarget) line += `: ${this.toolTarget}`;
83
+ }
79
84
  return line;
80
85
  }
81
86
 
@@ -94,6 +99,14 @@ class Indicator {
94
99
  function inferPhaseStep(indicator, toolName, toolInput) {
95
100
  const name = (toolName || '').toLowerCase();
96
101
 
102
+ indicator.lastToolTime = Date.now();
103
+
104
+ const rawTarget = typeof toolInput === 'object'
105
+ ? (toolInput.file_path || toolInput.path || toolInput.command || toolInput.pattern || '')
106
+ : String(toolInput || '');
107
+ const shortTarget = rawTarget.split('/').slice(-2).join('/').slice(0, 40);
108
+ indicator.toolTarget = shortTarget;
109
+
97
110
  if (name === 'write' || name === 'edit' || name === 'multiedit' || name === 'str_replace_editor' || name === 'strreplace') {
98
111
  indicator.updatePhase('coding');
99
112
  } else if (name === 'bash' || name === 'shell') {
@@ -119,9 +132,23 @@ function inferPhaseStep(indicator, toolName, toolInput) {
119
132
  indicator.updateStep('查阅文档');
120
133
  }
121
134
 
122
- const summary = typeof toolInput === 'object'
123
- ? (toolInput.path || toolInput.command || toolInput.pattern || JSON.stringify(toolInput).slice(0, 80))
124
- : String(toolInput || '').slice(0, 80);
135
+ let summary;
136
+ if (typeof toolInput === 'object') {
137
+ const target = toolInput.file_path || toolInput.path || '';
138
+ const cmd = toolInput.command || '';
139
+ const pattern = toolInput.pattern || '';
140
+ if (target) {
141
+ summary = target;
142
+ } else if (cmd) {
143
+ summary = cmd.slice(0, 200);
144
+ } else if (pattern) {
145
+ summary = `pattern: ${pattern}`;
146
+ } else {
147
+ summary = JSON.stringify(toolInput).slice(0, 200);
148
+ }
149
+ } else {
150
+ summary = String(toolInput || '').slice(0, 200);
151
+ }
125
152
  indicator.appendActivity(toolName, summary);
126
153
  }
127
154
 
package/src/prompts.js CHANGED
@@ -211,25 +211,83 @@ function buildScanPrompt(projectType, requirement) {
211
211
 
212
212
  /**
213
213
  * Build user prompt for add sessions.
214
+ * Structure: Role (primacy) → Context → CoT → TaskGuide → Instruction (recency)
214
215
  */
215
216
  function buildAddPrompt(instruction) {
217
+ const p = paths();
218
+ const projectRoot = getProjectRoot();
216
219
  const taskGuide = buildTaskGuide();
220
+
221
+ // --- Context injection: pre-read project state ---
222
+ let profileContext = '';
223
+ if (fs.existsSync(p.profile)) {
224
+ try {
225
+ const profile = JSON.parse(fs.readFileSync(p.profile, 'utf8'));
226
+ const stack = profile.tech_stack || {};
227
+ const parts = [];
228
+ if (stack.backend?.framework) parts.push(`后端: ${stack.backend.framework}`);
229
+ if (stack.frontend?.framework) parts.push(`前端: ${stack.frontend.framework}`);
230
+ if (stack.backend?.language) parts.push(`语言: ${stack.backend.language}`);
231
+ if (parts.length) profileContext = `项目技术栈: ${parts.join(', ')}`;
232
+ } catch { /* ignore */ }
233
+ }
234
+
235
+ let taskContext = '';
236
+ let recentExamples = '';
237
+ try {
238
+ const taskData = loadTasks();
239
+ if (taskData) {
240
+ const stats = getStats(taskData);
241
+ const features = taskData.features || [];
242
+ const maxId = features.length ? features[features.length - 1].id : 'feat-000';
243
+ const maxPriority = features.length ? Math.max(...features.map(f => f.priority || 0)) : 0;
244
+ const categories = [...new Set(features.map(f => f.category))].join(', ');
245
+
246
+ taskContext = `已有 ${stats.total} 个任务(${stats.done} done, ${stats.pending} pending, ${stats.failed} failed)。` +
247
+ `最大 id: ${maxId}, 最大 priority: ${maxPriority}。已有 category: ${categories}。`;
248
+
249
+ const recent = features.slice(-3);
250
+ if (recent.length) {
251
+ recentExamples = '已有任务格式参考(保持一致性):\n' +
252
+ recent.map(f => ` ${f.id}: "${f.description}" (category=${f.category}, steps=${f.steps.length}步, depends_on=[${f.depends_on.join(',')}])`).join('\n');
253
+ }
254
+ }
255
+ } catch { /* ignore */ }
256
+
217
257
  return [
218
- '重要:这是任务追加 session,不是常规编码 session。不执行 6 步流程。',
258
+ // --- Primacy zone: role + identity ---
259
+ '你是资深需求分析师,擅长将模糊需求分解为可执行的原子任务。',
260
+ '这是任务追加 session,不是编码 session。你只分解任务,不实现代码。',
219
261
  '',
220
- '步骤:',
221
- '1. 读取 .claude-coder/tasks.json 了解已有任务和最大 id/priority',
222
- '2. 读取 .claude-coder/project_profile.json 了解项目技术栈',
223
- '3. 根据用户指令追加新任务(status: pending)',
262
+
263
+ // --- Context layer ---
264
+ profileContext,
265
+ taskContext,
266
+ recentExamples,
267
+ `项目绝对路径: ${projectRoot}`,
224
268
  '',
269
+
270
+ // --- CoT: explicit thinking steps ---
271
+ '执行步骤(按顺序,不可跳过):',
272
+ '1. 读取 .claude-coder/tasks.json 和 .claude-coder/project_profile.json,全面了解项目现状',
273
+ '2. 分析用户指令:识别核心功能点,判断是单任务还是需要拆分为多任务',
274
+ '3. 检查重复:对比已有任务,避免功能重叠',
275
+ '4. 确定依赖:新任务的 depends_on 引用已有或新增任务的 id,形成 DAG',
276
+ '5. 分解任务:每个任务对应一个独立可测试的功能单元,description 简明(40字内),steps 具体可操作',
277
+ '6. 追加到 tasks.json,id 和 priority 从已有最大值递增,status: pending',
278
+ '7. git add -A && git commit -m "chore: add new tasks"',
279
+ '8. 写入 session_result.json',
280
+ '',
281
+
282
+ // --- Quality constraints ---
225
283
  taskGuide,
226
284
  '',
227
- '新任务 id 和 priority 从已有最大值递增。不修改已有任务,不实现代码。',
228
- 'git add -A && git commit -m "chore: add new tasks"',
229
- '写入 session_result.json',
285
+ '不修改已有任务,不实现代码。',
230
286
  '',
287
+
288
+ // --- Recency zone: user instruction (highest attention) ---
231
289
  `用户指令:${instruction}`,
232
- ].join('\n');
290
+ ].filter(Boolean).join('\n');
233
291
  }
234
292
 
235
293
  module.exports = {
package/src/runner.js CHANGED
@@ -301,6 +301,25 @@ async function run(requirement, opts = {}) {
301
301
  lastValidateLog: consecutiveFailures > 0 ? '上次校验失败' : '',
302
302
  });
303
303
 
304
+ if (sessionResult.stalled) {
305
+ log('warn', `Session ${session} 因停顿超时中断,跳过校验直接重试`);
306
+ consecutiveFailures++;
307
+ rollback(headBefore, '停顿超时');
308
+ if (consecutiveFailures >= MAX_RETRY) {
309
+ log('error', `连续失败 ${MAX_RETRY} 次,跳过当前任务`);
310
+ markTaskFailed();
311
+ consecutiveFailures = 0;
312
+ }
313
+ appendProgress({
314
+ session,
315
+ timestamp: new Date().toISOString(),
316
+ result: 'stalled',
317
+ cost: sessionResult.cost,
318
+ taskId,
319
+ });
320
+ continue;
321
+ }
322
+
304
323
  // Validate
305
324
  log('info', '开始 harness 校验 ...');
306
325
  const validateResult = await validate(headBefore);
package/src/session.js CHANGED
@@ -105,6 +105,16 @@ async function runCodingSession(sessionNum, opts = {}) {
105
105
 
106
106
  const editCounts = {};
107
107
  const EDIT_THRESHOLD = 5;
108
+ const stallTimeoutMs = config.stallTimeout * 1000;
109
+ let stallDetected = false;
110
+
111
+ const stallChecker = setInterval(() => {
112
+ const idleMs = Date.now() - indicator.lastToolTime;
113
+ if (idleMs > stallTimeoutMs && !stallDetected) {
114
+ stallDetected = true;
115
+ log('warn', `无新工具调用超过 ${Math.floor(idleMs / 60000)} 分钟,自动中断 session`);
116
+ }
117
+ }, 30000);
108
118
 
109
119
  try {
110
120
  const queryOpts = buildQueryOptions(config, opts);
@@ -115,13 +125,20 @@ async function runCodingSession(sessionNum, opts = {}) {
115
125
  hooks: [async (input) => {
116
126
  inferPhaseStep(indicator, input.tool_name, input.tool_input);
117
127
 
118
- const filePath = input.tool_input?.file_path || input.tool_input?.path || '';
119
- if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && filePath) {
120
- editCounts[filePath] = (editCounts[filePath] || 0) + 1;
121
- if (editCounts[filePath] > EDIT_THRESHOLD) {
128
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
129
+ const cmd = input.tool_input?.command || '';
130
+ const pattern = input.tool_input?.pattern || '';
131
+ const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
132
+ if (detail) {
133
+ logStream.write(`[${new Date().toISOString()}] ${input.tool_name}: ${detail}\n`);
134
+ }
135
+
136
+ if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && target) {
137
+ editCounts[target] = (editCounts[target] || 0) + 1;
138
+ if (editCounts[target] > EDIT_THRESHOLD) {
122
139
  return {
123
140
  decision: 'block',
124
- message: `已对 ${filePath} 编辑 ${editCounts[filePath]} 次,疑似死循环。请重新审视方案后再继续。`,
141
+ message: `已对 ${target} 编辑 ${editCounts[target]} 次,疑似死循环。请重新审视方案后再继续。`,
125
142
  };
126
143
  }
127
144
  }
@@ -135,21 +152,28 @@ async function runCodingSession(sessionNum, opts = {}) {
135
152
 
136
153
  const collected = [];
137
154
  for await (const message of session) {
155
+ if (stallDetected) {
156
+ log('warn', '停顿超时,中断消息循环');
157
+ break;
158
+ }
138
159
  collected.push(message);
139
160
  logMessage(message, logStream, indicator);
140
161
  }
141
162
 
163
+ clearInterval(stallChecker);
142
164
  logStream.end();
143
165
  indicator.stop();
144
166
 
145
167
  const result = extractResult(collected);
146
168
  return {
147
- exitCode: 0,
169
+ exitCode: stallDetected ? 2 : 0,
148
170
  cost: result?.total_cost_usd ?? null,
149
171
  tokenUsage: result?.usage ?? null,
150
172
  logFile,
173
+ stalled: stallDetected,
151
174
  };
152
175
  } catch (err) {
176
+ clearInterval(stallChecker);
153
177
  logStream.end();
154
178
  indicator.stop();
155
179
  log('error', `Claude SDK 错误: ${err.message}`);