codemini-cli 0.3.8 → 0.4.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.
@@ -78,57 +78,61 @@ export async function handleChat(args) {
78
78
  systemPrompt
79
79
  });
80
80
 
81
- if (parsed.prompt) {
82
- const result = await runtime.submit(parsed.prompt);
83
- if (result.text) console.log(result.text);
84
- return;
85
- }
81
+ try {
82
+ if (parsed.prompt) {
83
+ const result = await runtime.submit(parsed.prompt);
84
+ if (result.text) console.log(result.text);
85
+ return;
86
+ }
86
87
 
87
- if (parsed.plain || !process.stdout.isTTY) {
88
- await runPlainLoop(runtime);
89
- return;
90
- }
88
+ if (parsed.plain || !process.stdout.isTTY) {
89
+ await runPlainLoop(runtime);
90
+ return;
91
+ }
91
92
 
92
- const React = (await import('react')).default;
93
- const { render } = await import('ink');
94
- const { ChatApp } = await import('../tui/chat-app.js');
93
+ const React = (await import('react')).default;
94
+ const { render } = await import('ink');
95
+ const { ChatApp } = await import('../tui/chat-app.js');
95
96
 
96
- const instance = render(
97
- React.createElement(ChatApp, {
98
- runtime,
99
- sessionId: session.id,
100
- model: parsed.model || config.model.name,
101
- sdkProvider: config.sdk?.provider || 'openai-compatible',
102
- language: config.ui?.language || 'zh',
103
- shellName: config.shell?.default || 'powershell',
104
- safeMode: config.policy?.safe_mode !== false,
105
- version: pkg.version
106
- })
107
- );
97
+ const instance = render(
98
+ React.createElement(ChatApp, {
99
+ runtime,
100
+ sessionId: session.id,
101
+ model: parsed.model || config.model.name,
102
+ sdkProvider: config.sdk?.provider || 'openai-compatible',
103
+ language: config.ui?.language || 'zh',
104
+ shellName: config.shell?.default || 'powershell',
105
+ safeMode: config.policy?.safe_mode !== false,
106
+ version: pkg.version
107
+ })
108
+ );
108
109
 
109
- // Patch Ink's renderInteractiveFrame to never use clearTerminal.
110
- // Ink calls clearTerminal (ESC[2J + ESC[H]) when the output frame exceeds
111
- // the terminal viewport height, which resets the scroll position to the top
112
- // and prevents the user from scrolling freely during streaming.
113
- // By always using incremental logUpdate updates instead, old content scrolls
114
- // into the terminal's scrollback naturally and the user can scroll freely.
115
- const origRenderFrame = instance.renderInteractiveFrame;
116
- instance.renderInteractiveFrame = function (output, outputHeight, staticOutput) {
117
- const hasStaticOutput = staticOutput !== '';
118
- const outputToRender = output + '\n';
110
+ // Patch Ink's renderInteractiveFrame to never use clearTerminal.
111
+ // Ink calls clearTerminal (ESC[2J + ESC[H]) when the output frame exceeds
112
+ // the terminal viewport height, which resets the scroll position to the top
113
+ // and prevents the user from scrolling freely during streaming.
114
+ // By always using incremental logUpdate updates instead, old content scrolls
115
+ // into the terminal's scrollback naturally and the user can scroll freely.
116
+ const origRenderFrame = instance.renderInteractiveFrame;
117
+ instance.renderInteractiveFrame = function (output, outputHeight, staticOutput) {
118
+ const hasStaticOutput = staticOutput !== '';
119
+ const outputToRender = output + '\n';
119
120
 
120
- if (hasStaticOutput) {
121
- this.fullStaticOutput += staticOutput;
122
- this.log.clear();
123
- this.options.stdout.write(staticOutput);
124
- this.log(outputToRender);
125
- } else if (output !== this.lastOutput || this.log.isCursorDirty()) {
126
- this.throttledLog(outputToRender);
127
- }
128
- this.lastOutput = output;
129
- this.lastOutputToRender = outputToRender;
130
- this.lastOutputHeight = outputHeight;
131
- };
121
+ if (hasStaticOutput) {
122
+ this.fullStaticOutput += staticOutput;
123
+ this.log.clear();
124
+ this.options.stdout.write(staticOutput);
125
+ this.log(outputToRender);
126
+ } else if (output !== this.lastOutput || this.log.isCursorDirty()) {
127
+ this.throttledLog(outputToRender);
128
+ }
129
+ this.lastOutput = output;
130
+ this.lastOutputToRender = outputToRender;
131
+ this.lastOutputHeight = outputHeight;
132
+ };
132
133
 
133
- await instance.waitUntilExit();
134
+ await instance.waitUntilExit();
135
+ } finally {
136
+ await runtime.dispose?.();
137
+ }
134
138
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { getConfigFilePath, getSessionsDir, getSkillsDir } from '../core/paths.js';
4
5
  import { loadConfig } from '../core/config-store.js';
5
6
 
@@ -34,8 +35,20 @@ async function checkGateway(config) {
34
35
  }
35
36
  }
36
37
 
37
- export async function handleDoctor() {
38
- const config = await loadConfig();
38
+ async function commandExists(command) {
39
+ const probe = process.platform === 'win32' ? 'where' : 'which';
40
+ const result = spawnSync(probe, [command], { stdio: 'ignore' });
41
+ return result.status === 0;
42
+ }
43
+
44
+ export async function handleDoctor({
45
+ loadConfigFn = loadConfig,
46
+ checkPathWritableFn = checkPathWritable,
47
+ checkGatewayFn = checkGateway,
48
+ commandExistsFn = commandExists,
49
+ writeLine = (line) => console.log(line)
50
+ } = {}) {
51
+ const config = await loadConfigFn();
39
52
  const checks = [];
40
53
 
41
54
  checks.push({
@@ -52,32 +65,39 @@ export async function handleDoctor() {
52
65
 
53
66
  checks.push({
54
67
  name: 'Config file writable',
55
- ok: await checkPathWritable(path.dirname(getConfigFilePath())),
68
+ ok: await checkPathWritableFn(path.dirname(getConfigFilePath())),
56
69
  detail: getConfigFilePath()
57
70
  });
58
71
 
59
72
  checks.push({
60
73
  name: 'Sessions dir writable',
61
- ok: await checkPathWritable(getSessionsDir()),
74
+ ok: await checkPathWritableFn(getSessionsDir()),
62
75
  detail: getSessionsDir()
63
76
  });
64
77
 
65
78
  checks.push({
66
79
  name: 'Skills dir writable',
67
- ok: await checkPathWritable(getSkillsDir()),
80
+ ok: await checkPathWritableFn(getSkillsDir()),
68
81
  detail: getSkillsDir()
69
82
  });
70
83
 
71
- const gateway = await checkGateway(config);
84
+ const gateway = await checkGatewayFn(config);
72
85
  checks.push({
73
86
  name: 'Gateway connectivity',
74
87
  ok: gateway.ok,
75
88
  detail: gateway.reason
76
89
  });
77
90
 
91
+ const hasFff = await commandExistsFn('fff-mcp');
92
+ checks.push({
93
+ name: 'FFF MCP availability',
94
+ ok: hasFff,
95
+ detail: hasFff ? 'found fff-mcp' : 'fff-mcp not found in PATH'
96
+ });
97
+
78
98
  for (const check of checks) {
79
99
  const mark = check.ok ? 'OK' : 'FAIL';
80
- console.log(`[${mark}] ${check.name}: ${check.detail}`);
100
+ writeLine(`[${mark}] ${check.name}: ${check.detail}`);
81
101
  }
82
102
 
83
103
  const failed = checks.filter((c) => !c.ok).length;
@@ -85,25 +85,29 @@ async function runHarness({ role, task, config, systemPrompt, model, maxSteps })
85
85
  if (!HARNESS_ROLES.includes(role)) {
86
86
  throw new Error(`Unknown harness role: ${role}. Available: ${HARNESS_ROLES.join(', ')}`);
87
87
  }
88
- const { definitions, handlers, formatters, deferredDefinitions } = getBuiltinTools({
88
+ const { definitions, handlers, formatters, deferredDefinitions, dispose } = getBuiltinTools({
89
89
  workspaceRoot: process.cwd(),
90
90
  config
91
91
  });
92
- const filtered = filterToolsForRole(definitions, handlers, deferredDefinitions, role);
93
- const rolePrompt = getSubAgentRolePrompt(role);
92
+ try {
93
+ const filtered = filterToolsForRole(definitions, handlers, deferredDefinitions, role);
94
+ const rolePrompt = getSubAgentRolePrompt(role);
94
95
 
95
- const result = await runAgentLoop({
96
- systemPrompt: `${systemPrompt}\n${rolePrompt}`,
97
- userPrompt: task,
98
- model: model || config.model.name,
99
- toolDefinitions: filtered.definitions,
100
- toolHandlers: filtered.handlers,
101
- toolFormatters: formatters,
102
- deferredDefinitions: filtered.deferredDefinitions,
103
- maxSteps,
104
- requestCompletion: makeCompletionFn(config)
105
- });
106
- return result;
96
+ const result = await runAgentLoop({
97
+ systemPrompt: `${systemPrompt}\n${rolePrompt}`,
98
+ userPrompt: task,
99
+ model: model || config.model.name,
100
+ toolDefinitions: filtered.definitions,
101
+ toolHandlers: filtered.handlers,
102
+ toolFormatters: formatters,
103
+ deferredDefinitions: filtered.deferredDefinitions,
104
+ maxSteps,
105
+ requestCompletion: makeCompletionFn(config)
106
+ });
107
+ return result;
108
+ } finally {
109
+ await dispose?.();
110
+ }
107
111
  }
108
112
 
109
113
  function extractJsonBlock(text) {
@@ -263,21 +267,25 @@ export async function handleRun(args) {
263
267
  return;
264
268
  }
265
269
 
266
- const { definitions, handlers, formatters, deferredDefinitions } = getBuiltinTools({
270
+ const { definitions, handlers, formatters, deferredDefinitions, dispose } = getBuiltinTools({
267
271
  workspaceRoot: process.cwd(),
268
272
  config
269
273
  });
270
- const result = await runAgentLoop({
271
- systemPrompt,
272
- userPrompt: parsed.task,
273
- model: parsed.model || config.model.name,
274
- toolDefinitions: definitions,
275
- toolHandlers: handlers,
276
- toolFormatters: formatters,
277
- deferredDefinitions,
278
- maxSteps: parsed.maxSteps,
279
- requestCompletion: makeCompletionFn(config)
280
- });
274
+ try {
275
+ const result = await runAgentLoop({
276
+ systemPrompt,
277
+ userPrompt: parsed.task,
278
+ model: parsed.model || config.model.name,
279
+ toolDefinitions: definitions,
280
+ toolHandlers: handlers,
281
+ toolFormatters: formatters,
282
+ deferredDefinitions,
283
+ maxSteps: parsed.maxSteps,
284
+ requestCompletion: makeCompletionFn(config)
285
+ });
281
286
 
282
- console.log(result.text);
287
+ console.log(result.text);
288
+ } finally {
289
+ await dispose?.();
290
+ }
283
291
  }
@@ -3,6 +3,9 @@ import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
4
  import { BoundedCache } from './bounded-cache.js';
5
5
  import { trimInline as _trimInline, normalizePath } from './string-utils.js';
6
+ import { captureToInbox, listInbox } from './memory-store.js';
7
+ import { requiresApprovalEvaluation } from './command-risk.js';
8
+ import { getToolOutputSanitizeOptions, sanitizeTextForModel } from './tool-output.js';
6
9
 
7
10
  /**
8
11
  * 安全解析 JSON 字符串。
@@ -161,7 +164,7 @@ function emptyToolResultMarker(toolName) {
161
164
  }
162
165
 
163
166
  function clipToolResult(result, maxChars = 12000) {
164
- const raw = typeof result === 'string' ? result : JSON.stringify(result);
167
+ const raw = sanitizeTextForModel(typeof result === 'string' ? result : JSON.stringify(result));
165
168
  if (!maxChars || raw.length <= maxChars) return raw;
166
169
  return `${raw.slice(0, maxChars)}\n... [tool result truncated ${raw.length - maxChars} chars]`;
167
170
  }
@@ -169,8 +172,9 @@ function clipToolResult(result, maxChars = 12000) {
169
172
  function compactToolResult(result, toolName, args, maxChars = 12000) {
170
173
  if (result === null || result === undefined) return 'no output';
171
174
  if (typeof result === 'string') {
172
- if (result.length <= maxChars) return result;
173
- return `${result.slice(0, maxChars)}\n... [tool result truncated ${result.length - maxChars} chars, original: ${result.length}]`;
175
+ const sanitized = sanitizeTextForModel(result);
176
+ if (sanitized.length <= maxChars) return sanitized;
177
+ return `${sanitized.slice(0, maxChars)}\n... [tool result truncated ${sanitized.length - maxChars} chars, original: ${sanitized.length}]`;
174
178
  }
175
179
  if (typeof result !== 'object') return String(result);
176
180
 
@@ -361,12 +365,103 @@ export function checkReadDedup(filePath, startLine, endLine, mtimeMs) {
361
365
  const READ_ONLY_TOOLS = new Set([
362
366
  'read', 'grep', 'glob', 'list',
363
367
  'ast_query', 'read_ast_node',
368
+ 'web_fetch', 'web_search',
364
369
  'list_background_tasks', 'get_background_task',
365
370
  'read_plan'
366
371
  ]);
367
372
 
373
+ // ─── Auto-capture tool errors to dream loop inbox ────────────────────
374
+
375
+ const DREAM_AUTO_CAPTURE_TOOLS = new Set([
376
+ 'edit', 'write', 'run', 'delete'
377
+ ]);
378
+
379
+ const DREAM_AUTO_CAPTURE_COOLDOWN_MS = 60_000;
380
+ const lastAutoCaptureByTool = new Map();
381
+
382
+ function shouldAutoCaptureError(toolName, message) {
383
+ if (!DREAM_AUTO_CAPTURE_TOOLS.has(toolName)) return false;
384
+ const now = Date.now();
385
+ const lastTime = lastAutoCaptureByTool.get(toolName) || 0;
386
+ if (now - lastTime < DREAM_AUTO_CAPTURE_COOLDOWN_MS) return false;
387
+ const noisePatterns = [
388
+ /file already exists/i,
389
+ /no such file/i,
390
+ /not found$/i,
391
+ /already exists$/i,
392
+ /cancelled/i,
393
+ /aborted/i,
394
+ /blocked by (?:safe mode|policy|dangerous command)/i,
395
+ /exit 127/i,
396
+ /command not found/i,
397
+ /permission denied/i,
398
+ /args\?\s/i,
399
+ /Raw tool arguments/i,
400
+ /edit requires/i,
401
+ /write requires/i,
402
+ /requires file/i,
403
+ /path.*outside workspace/i,
404
+ /escapes workspace/i
405
+ ];
406
+ if (noisePatterns.some((p) => p.test(message))) return false;
407
+ lastAutoCaptureByTool.set(toolName, now);
408
+ return true;
409
+ }
410
+
411
+ function fireAndForgetCapture(toolName, message, args) {
412
+ const summary = `[${toolName}] ${String(message).slice(0, 120)}`;
413
+ const details = args
414
+ ? `Tool: ${toolName}\nError: ${message}\nArgs: ${JSON.stringify(args).slice(0, 300)}`
415
+ : `Tool: ${toolName}\nError: ${message}`;
416
+ captureToInbox({
417
+ scope: 'auto',
418
+ type: 'failure',
419
+ summary,
420
+ details,
421
+ source: 'auto-capture'
422
+ }).catch(() => {});
423
+ }
424
+
425
+ async function checkAutoDreamThreshold(config) {
426
+ const threshold = Number(config?.memory?.auto_dream_threshold || 10);
427
+ if (threshold <= 0) return false;
428
+ try {
429
+ const entries = await listInbox();
430
+ return entries.length >= threshold;
431
+ } catch {
432
+ return false;
433
+ }
434
+ }
435
+
368
436
  // ─── Exported helpers ────────────────────────────────────────────────
369
437
 
438
+ function extractFileChange(toolName, result) {
439
+ if (!result || typeof result !== 'object') return null;
440
+ const FILE_TOOLS = new Set(['edit', 'write', 'delete']);
441
+ if (!FILE_TOOLS.has(toolName)) return null;
442
+
443
+ /* delete */
444
+ if ('deleted' in result && result.deleted) {
445
+ return { path: String(result.path || ''), action: 'delete', linesAdded: 0, linesRemoved: 0 };
446
+ }
447
+
448
+ /* edit / write */
449
+ if ('path' in result && 'action' in result) {
450
+ const action = String(result.action || '');
451
+ const isCreate = action === 'create';
452
+ const added = Number(result.lines_added || 0);
453
+ const removed = Number(result.lines_removed || 0);
454
+ return {
455
+ path: String(result.path || ''),
456
+ action: isCreate ? 'create' : 'edit',
457
+ linesAdded: added,
458
+ linesRemoved: removed
459
+ };
460
+ }
461
+
462
+ return null;
463
+ }
464
+
370
465
  export function summarizeToolResult(result) {
371
466
  if (result === null || result === undefined) return 'no output';
372
467
  if (typeof result === 'string') {
@@ -586,7 +681,7 @@ function blockedExplorationReason(toolName, args, state) {
586
681
  const top = topLevelPath(target);
587
682
  if (!top) return '';
588
683
 
589
- if (['skills', 'souls', 'templates', '.codemini', '.codemini-project'].includes(top)) {
684
+ if (['skills', 'souls', 'templates', '.codemini', '.codemini-global'].includes(top)) {
590
685
  return `Skip ${top}/ for broad repository analysis unless the user explicitly asks for it. Inspect relevant source files first.`;
591
686
  }
592
687
  return '';
@@ -682,14 +777,17 @@ function formatToolDisplayName(name, args) {
682
777
  // ─── Format a single tool result using per-tool formatter or fallback ──
683
778
 
684
779
  function formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars) {
780
+ const sanitizeOptions = getToolOutputSanitizeOptions(toolName);
685
781
  if (toolFormatters && typeof toolFormatters[toolName] === 'function') {
686
782
  const formatted = toolFormatters[toolName](toolResult, args);
687
783
  if (typeof formatted === 'string') {
688
- return formatted.trim() ? formatted : emptyToolResultMarker(toolName);
784
+ const sanitized = sanitizeTextForModel(formatted, sanitizeOptions);
785
+ return sanitized.trim() ? sanitized : emptyToolResultMarker(toolName);
689
786
  }
690
787
  }
691
788
  const fallback = compactToolResult(toolResult, toolName, args, toolResultMaxChars);
692
- return String(fallback || '').trim() ? fallback : emptyToolResultMarker(toolName);
789
+ const sanitizedFallback = sanitizeTextForModel(fallback, sanitizeOptions);
790
+ return String(sanitizedFallback || '').trim() ? sanitizedFallback : emptyToolResultMarker(toolName);
693
791
  }
694
792
 
695
793
  // ─── Main agent loop ────────────────────────────────────────────────
@@ -711,7 +809,8 @@ export async function runAgentLoop({
711
809
  toolFormatters = {},
712
810
  deferredDefinitions = {},
713
811
  signal,
714
- skipAnalysisNudge = false
812
+ skipAnalysisNudge = false,
813
+ config = {}
715
814
  }) {
716
815
  const messages = [];
717
816
  if (systemPrompt) {
@@ -729,10 +828,36 @@ export async function runAgentLoop({
729
828
  let pendingSummaryNudges = 0;
730
829
  const analysisGuard = createAnalysisGuardState(userPrompt);
731
830
  const alwaysAllowSet = new Set((Array.isArray(alwaysAllowTools) ? alwaysAllowTools : []).map((t) => String(t)));
831
+ let autoDreamChecked = false;
732
832
 
733
833
  // Mutable tool list — grows as tool_search loads deferred tools
734
834
  const activeTools = [...toolDefinitions];
735
835
 
836
+ async function maybeRunAutoDream() {
837
+ if (autoDreamChecked) return;
838
+ autoDreamChecked = true;
839
+ if (executionMode === 'plan') return;
840
+ const autoDreamResult = await checkAutoDreamThreshold(config);
841
+ if (!autoDreamResult) return;
842
+ const dreamTool = toolHandlers['dream_consolidate'];
843
+ if (typeof dreamTool !== 'function') return;
844
+ if (onEvent) onEvent({ type: 'dream:auto', message: 'inbox threshold reached' });
845
+ try {
846
+ const report = await dreamTool({});
847
+ if (onEvent) {
848
+ onEvent({ type: 'dream:complete', report });
849
+ }
850
+ } catch (error) {
851
+ if (onEvent) {
852
+ onEvent({
853
+ type: 'dream:complete',
854
+ report: { ok: false, error: String(error?.message || error || 'unknown dream error') }
855
+ });
856
+ }
857
+ // Auto-dream is best-effort; don't block the loop
858
+ }
859
+ }
860
+
736
861
  for (let step = 0; step < maxSteps; step += 1) {
737
862
  // 检查是否已被用户中止
738
863
  if (signal?.aborted) {
@@ -806,6 +931,7 @@ export async function runAgentLoop({
806
931
  continue;
807
932
  }
808
933
  finalText = assistantText;
934
+ await maybeRunAutoDream();
809
935
  return { text: finalText, messages, steps: step + 1 };
810
936
  }
811
937
 
@@ -822,6 +948,7 @@ export async function runAgentLoop({
822
948
  ]
823
949
  .filter(Boolean)
824
950
  .join('\n');
951
+ await maybeRunAutoDream();
825
952
  return { text: finalText.trim(), messages, steps: step + 1 };
826
953
  }
827
954
 
@@ -841,7 +968,11 @@ export async function runAgentLoop({
841
968
  let approved = true;
842
969
  let approvalArgs = args;
843
970
  let preflightErrorContent = '';
844
- const needsApproval = toolName === 'delete' || (executionMode === 'normal' && !alwaysAllowSet.has(toolName));
971
+ const isSafeModeRun = toolName === 'run'
972
+ && config?.policy?.safe_mode !== false
973
+ && requiresApprovalEvaluation(args?.command || '', config?.shell?.default);
974
+ const needsApproval = toolName === 'delete' || isSafeModeRun
975
+ || (executionMode === 'normal' && !alwaysAllowSet.has(toolName));
845
976
  if (needsApproval) {
846
977
  approved = false;
847
978
  const handler = toolHandlers[toolName];
@@ -857,6 +988,31 @@ export async function runAgentLoop({
857
988
  preflightErrorContent = clipToolResult({ error: message }, toolResultMaxChars);
858
989
  }
859
990
  }
991
+ /* Run tool: safe mode LLM-based command evaluation */
992
+ if (toolName === 'run' && isSafeModeRun && !preflightErrorContent) {
993
+ try {
994
+ const { evaluateCommandWithLLM } = await import('./command-evaluator.js');
995
+ const evaluation = await evaluateCommandWithLLM({
996
+ command: args?.command || '',
997
+ config,
998
+ workspaceRoot: config?.workspaceRoot || process.cwd()
999
+ });
1000
+ approvalArgs = { ...args, _risk: evaluation.risk, _evaluation: evaluation };
1001
+ /* LLM says low-risk + allow → auto-approve, skip confirmation panel */
1002
+ if (evaluation.risk === 'low' && evaluation.recommendation === 'allow') {
1003
+ approvalResults.set(call.id, { approved: true, args: approvalArgs });
1004
+ continue;
1005
+ }
1006
+ } catch (_) {
1007
+ approvalArgs = { ...args, _risk: 'high', _evaluation: null };
1008
+ }
1009
+ if (typeof handler?.prepareApproval === 'function') {
1010
+ try {
1011
+ const approval = await handler.prepareApproval(approvalArgs);
1012
+ approvalArgs = { ...approvalArgs, approval };
1013
+ } catch (_) { /* skip */ }
1014
+ }
1015
+ }
860
1016
  if (preflightErrorContent) {
861
1017
  approvalResults.set(call.id, {
862
1018
  approved: false,
@@ -871,7 +1027,8 @@ export async function runAgentLoop({
871
1027
  name: toolName,
872
1028
  displayName,
873
1029
  arguments: approvalArgs,
874
- approvalDetails: toolName === 'delete' ? approvalArgs.approval : undefined
1030
+ approvalDetails: toolName === 'delete' ? approvalArgs.approval
1031
+ : (toolName === 'run' ? approvalArgs.approval : undefined)
875
1032
  });
876
1033
  approved = Boolean(decision?.approved);
877
1034
  }
@@ -941,6 +1098,9 @@ export async function runAgentLoop({
941
1098
  if (onEvent) {
942
1099
  onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary: trimInline(message, 120) });
943
1100
  }
1101
+ if (shouldAutoCaptureError(toolName, message)) {
1102
+ fireAndForgetCapture(toolName, message, effectiveArgs);
1103
+ }
944
1104
  return {
945
1105
  callId: call.id,
946
1106
  content: clipToolResult({ error: message }, toolResultMaxChars),
@@ -949,8 +1109,28 @@ export async function runAgentLoop({
949
1109
  }
950
1110
 
951
1111
  const durationMs = Date.now() - startedAt;
1112
+ /* 提取文件改动统计 */
1113
+ const fileChange = extractFileChange(toolName, toolResult);
952
1114
  if (onEvent) {
953
- onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary: summarizeToolResult(toolResult) });
1115
+ onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary: summarizeToolResult(toolResult), fileChange });
1116
+ }
1117
+
1118
+ // Auto-capture non-throwing tool failures (e.g. shell non-zero exit)
1119
+ if (toolResult && typeof toolResult === 'object') {
1120
+ const exitCode = toolResult.code ?? toolResult.exitCode;
1121
+ const stderr = String(toolResult.stderr || '');
1122
+ if (typeof exitCode === 'number' && exitCode !== 0 && stderr) {
1123
+ const failMsg = `exit ${exitCode}: ${stderr.slice(0, 120)}`;
1124
+ if (shouldAutoCaptureError(toolName, failMsg)) {
1125
+ fireAndForgetCapture(toolName, failMsg, effectiveArgs);
1126
+ }
1127
+ }
1128
+ if (toolResult.error) {
1129
+ const errMsg = String(toolResult.error).slice(0, 120);
1130
+ if (shouldAutoCaptureError(toolName, errMsg)) {
1131
+ fireAndForgetCapture(toolName, errMsg, effectiveArgs);
1132
+ }
1133
+ }
954
1134
  }
955
1135
 
956
1136
  // P1b: Use per-tool formatter if available, else fallback
@@ -1031,6 +1211,7 @@ export async function runAgentLoop({
1031
1211
  }
1032
1212
 
1033
1213
  const fallback = lastAssistantText || 'Stopped before final response.';
1214
+ await maybeRunAutoDream();
1034
1215
  return {
1035
1216
  text: `${fallback}\n\n[stopped] Reached max tool steps (${maxSteps}). Try a narrower prompt or increase execution.max_steps.`,
1036
1217
  messages,