@miphamai/cli 0.27.0 → 0.29.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,551 @@
1
+ /**
2
+ * CRSI Phase 3: Security Sandbox for Controlled Code Self-Modification
3
+ *
4
+ * Provides a safe execution environment where AI-generated code changes
5
+ * are:
6
+ * 1. Applied in an isolated git worktree (no effect on working tree)
7
+ * 2. Validated against the full test suite
8
+ * 3. Auto-rolled back on failure
9
+ * 4. Gated by human approval before merging
10
+ *
11
+ * Architecture:
12
+ * AI proposes change → CrsiSandbox applies in worktree → runs tests →
13
+ * if pass → presents diff for human approval → if approved → merges
14
+ * if fail → auto-discards worktree → logs failure for CRSI learning
15
+ */
16
+
17
+ import { execSync } from 'node:child_process'
18
+ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { tmpdir, homedir } from 'node:os'
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ // ── Types ──
24
+
25
+ export interface CrsiModification {
26
+ /** Unique ID for this modification attempt */
27
+ id: string
28
+ /** Human-readable description of what this change does */
29
+ description: string
30
+ /** The file path (relative to repo root) to modify */
31
+ filePath: string
32
+ /** The file content after modification */
33
+ newContent: string
34
+ /** The original file content (for rollback verification) */
35
+ originalContent: string
36
+ /** CRSI insight that triggered this modification */
37
+ crsiInsightId?: string
38
+ /** CRSI rule that generated this modification */
39
+ crsiRuleId?: string
40
+ /** Timestamp */
41
+ timestamp: string
42
+ }
43
+
44
+ export interface CrsiModificationResult {
45
+ modification: CrsiModification
46
+ /** Whether the modification was applied successfully */
47
+ applied: boolean
48
+ /** Test results after applying the modification */
49
+ testResult?: CrsiTestResult
50
+ /** Git diff of the change */
51
+ diff?: string
52
+ /** Error message if something failed */
53
+ error?: string
54
+ /** Current phase of the sandbox pipeline */
55
+ phase:
56
+ 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
57
+ }
58
+
59
+ export interface CrsiTestResult {
60
+ /** Whether all tests passed */
61
+ passed: boolean
62
+ /** Total test count */
63
+ totalTests: number
64
+ /** Failed test count */
65
+ failedTests: number
66
+ /** Test output (truncated) */
67
+ output: string
68
+ }
69
+
70
+ export interface CrsiSessionReport {
71
+ sessionId: string
72
+ modifications: CrsiModificationResult[]
73
+ summary: {
74
+ total: number
75
+ applied: number
76
+ passed: number
77
+ merged: number
78
+ rolledBack: number
79
+ }
80
+ startedAt: string
81
+ completedAt?: string
82
+ }
83
+
84
+ // ── Constants ──
85
+
86
+ const WORKTREE_PREFIX = 'crsi-sandbox-'
87
+ const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
88
+ const TEST_TIMEOUT_MS = 120_000 // 2 minutes
89
+ const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
90
+
91
+ // ── Sandbox ──
92
+
93
+ export class CrsiSandbox {
94
+ private repoRoot: string
95
+ private worktreePath?: string
96
+ private worktreeBranch?: string
97
+ private sessionReport: CrsiSessionReport
98
+ private currentModification?: CrsiModification
99
+
100
+ constructor(repoRoot: string = process.cwd()) {
101
+ this.repoRoot = resolve(repoRoot)
102
+ this.sessionReport = {
103
+ sessionId: `crsi-session-${Date.now().toString(36)}`,
104
+ modifications: [],
105
+ summary: { total: 0, applied: 0, passed: 0, merged: 0, rolledBack: 0 },
106
+ startedAt: new Date().toISOString(),
107
+ }
108
+
109
+ // Ensure report directory exists
110
+ mkdirSync(REPORT_DIR, { recursive: true })
111
+ }
112
+
113
+ // ── Public API ──
114
+
115
+ /**
116
+ * Stage 1: Create an isolated git worktree for safe modification.
117
+ *
118
+ * Creates a temporary worktree on a new branch. All subsequent
119
+ * modifications happen in this isolated environment — the user's
120
+ * working tree is never touched.
121
+ */
122
+ createWorktree(): { worktreePath: string; branch: string } {
123
+ // Clean up any stale worktrees from previous crashed sessions
124
+ this.cleanupStaleWorktrees()
125
+
126
+ const branchName = `${WORKTREE_PREFIX}${randomUUID().slice(0, 8)}`
127
+ const worktreeDir = join(tmpdir(), branchName)
128
+
129
+ // Ensure the dir doesn't already exist
130
+ if (existsSync(worktreeDir)) {
131
+ rmSync(worktreeDir, { recursive: true, force: true })
132
+ }
133
+
134
+ try {
135
+ execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, {
136
+ cwd: this.repoRoot,
137
+ timeout: 30_000,
138
+ encoding: 'utf-8',
139
+ })
140
+ } catch (err) {
141
+ throw new Error(`Failed to create CRSI worktree: ${String(err)}`)
142
+ }
143
+
144
+ this.worktreePath = worktreeDir
145
+ this.worktreeBranch = branchName
146
+
147
+ return { worktreePath: worktreeDir, branch: branchName }
148
+ }
149
+
150
+ /**
151
+ * Stage 2: Apply a modification in the sandbox worktree.
152
+ *
153
+ * Writes the new content to the target file inside the worktree.
154
+ * The modification is NOT applied to the user's working tree.
155
+ */
156
+ applyModification(mod: CrsiModification): CrsiModificationResult {
157
+ if (!this.worktreePath) {
158
+ return {
159
+ modification: mod,
160
+ applied: false,
161
+ phase: 'pending',
162
+ error: 'No worktree created. Call createWorktree() first.',
163
+ }
164
+ }
165
+
166
+ this.sessionReport.summary.total++
167
+ this.currentModification = mod
168
+
169
+ const result: CrsiModificationResult = {
170
+ modification: mod,
171
+ applied: false,
172
+ phase: 'pending',
173
+ }
174
+
175
+ const targetPath = join(this.worktreePath, mod.filePath)
176
+
177
+ // Verify the file exists in the worktree
178
+ if (!existsSync(targetPath)) {
179
+ result.error = `File not found in worktree: ${mod.filePath}`
180
+ result.phase = 'failed'
181
+ this.sessionReport.modifications.push(result)
182
+ return result
183
+ }
184
+
185
+ // Verify original content matches (safety check)
186
+ try {
187
+ const currentContent = readFileSync(targetPath, 'utf-8')
188
+ if (currentContent !== mod.originalContent && mod.originalContent) {
189
+ result.error =
190
+ 'Original content mismatch — file may have been modified since the audit. Aborting.'
191
+ result.phase = 'failed'
192
+ this.sessionReport.modifications.push(result)
193
+ return result
194
+ }
195
+ } catch {
196
+ // If we can't read the file, proceed anyway — it's a worktree
197
+ }
198
+
199
+ // Apply the modification
200
+ try {
201
+ writeFileSync(targetPath, mod.newContent, 'utf-8')
202
+ result.applied = true
203
+ result.phase = 'applied'
204
+ this.sessionReport.summary.applied++
205
+
206
+ // Generate diff
207
+ try {
208
+ result.diff = execSync(`git diff -- "${mod.filePath}"`, {
209
+ cwd: this.worktreePath,
210
+ timeout: 10_000,
211
+ encoding: 'utf-8',
212
+ })
213
+ } catch {
214
+ // Diff generation is best-effort
215
+ }
216
+ } catch (err) {
217
+ result.error = `Failed to apply modification: ${String(err)}`
218
+ result.phase = 'failed'
219
+ }
220
+
221
+ this.sessionReport.modifications.push(result)
222
+ return result
223
+ }
224
+
225
+ /**
226
+ * Stage 3: Run the test suite against the modified worktree.
227
+ *
228
+ * Only modifications that have been successfully applied can be tested.
229
+ * If tests fail, the modification is marked as 'failed' and will be
230
+ * rolled back.
231
+ */
232
+ runTests(): CrsiTestResult {
233
+ if (!this.worktreePath) {
234
+ return { passed: false, totalTests: 0, failedTests: 0, output: 'No worktree available.' }
235
+ }
236
+
237
+ const result: CrsiTestResult = { passed: false, totalTests: 0, failedTests: 0, output: '' }
238
+
239
+ try {
240
+ const output = execSync('pnpm test 2>&1', {
241
+ cwd: this.worktreePath,
242
+ timeout: TEST_TIMEOUT_MS,
243
+ encoding: 'utf-8',
244
+ })
245
+
246
+ result.output = output.slice(-5000) // Keep last 5000 chars
247
+
248
+ // Parse test results from vitest output
249
+ const testMatch = output.match(/Tests\s+(\d+)\s+passed/)
250
+ const failMatch = output.match(/(\d+)\s+failed/)
251
+ const totalMatch = output.match(/Tests\s+(\d+)\s+passed.*?(\d+)\s+total/)
252
+
253
+ if (totalMatch) {
254
+ result.totalTests = parseInt(totalMatch[2]!, 10)
255
+ }
256
+ if (testMatch && !failMatch) {
257
+ result.passed = true
258
+ result.failedTests = 0
259
+ } else if (failMatch) {
260
+ result.failedTests = parseInt(failMatch[1]!, 10)
261
+ }
262
+
263
+ // Update all pending modifications to 'testing' → 'passed'/'failed'
264
+ for (const modResult of this.sessionReport.modifications) {
265
+ if (modResult.phase === 'applied') {
266
+ modResult.phase = 'testing'
267
+ modResult.testResult = result
268
+ }
269
+ if (modResult.phase === 'testing') {
270
+ modResult.phase = result.passed ? 'passed' : 'failed'
271
+ if (result.passed) {
272
+ this.sessionReport.summary.passed++
273
+ }
274
+ }
275
+ }
276
+ } catch (err) {
277
+ result.output = String(err).slice(0, 5000)
278
+ result.failedTests = 1
279
+
280
+ // Mark all applied modifications as failed
281
+ for (const modResult of this.sessionReport.modifications) {
282
+ if (modResult.phase === 'applied' || modResult.phase === 'testing') {
283
+ modResult.phase = 'failed'
284
+ modResult.testResult = result
285
+ }
286
+ }
287
+ }
288
+
289
+ return result
290
+ }
291
+
292
+ /**
293
+ * Stage 4: Get the human-approvable diff of all modifications.
294
+ *
295
+ * Returns the full git diff so a human can review before approving.
296
+ */
297
+ getDiff(): string {
298
+ if (!this.worktreePath) return ''
299
+
300
+ try {
301
+ return execSync('git diff', {
302
+ cwd: this.worktreePath,
303
+ timeout: 10_000,
304
+ encoding: 'utf-8',
305
+ })
306
+ } catch {
307
+ return '(diff unavailable)'
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Stage 5: Merge modifications back to the main repo.
313
+ *
314
+ * ONLY call this after human approval and successful test run.
315
+ * Cherry-picks the changes from the worktree branch to the current branch.
316
+ */
317
+ merge(): { success: boolean; message: string } {
318
+ if (!this.worktreePath || !this.worktreeBranch) {
319
+ return { success: false, message: 'No worktree to merge from.' }
320
+ }
321
+
322
+ // Check that all modifications passed tests
323
+ const failedCount = this.sessionReport.modifications.filter((m) => m.phase === 'failed').length
324
+ if (failedCount > 0) {
325
+ return {
326
+ success: false,
327
+ message: `${failedCount} modification(s) failed tests. Cannot merge.`,
328
+ }
329
+ }
330
+
331
+ try {
332
+ // Commit in worktree
333
+ execSync('git add -A', { cwd: this.worktreePath, timeout: 10_000 })
334
+ execSync(
335
+ `git commit -m "CRSI auto-modification: ${this.sessionReport.sessionId}" --allow-empty`,
336
+ { cwd: this.worktreePath, timeout: 10_000 },
337
+ )
338
+
339
+ // Cherry-pick to main repo
340
+ const commitHash = execSync('git rev-parse HEAD', {
341
+ cwd: this.worktreePath,
342
+ timeout: 10_000,
343
+ encoding: 'utf-8',
344
+ }).trim()
345
+
346
+ execSync(`git cherry-pick "${commitHash}"`, {
347
+ cwd: this.repoRoot,
348
+ timeout: 30_000,
349
+ })
350
+
351
+ // Mark as merged
352
+ for (const modResult of this.sessionReport.modifications) {
353
+ if (modResult.phase === 'passed' || modResult.phase === 'approved') {
354
+ modResult.phase = 'merged'
355
+ }
356
+ }
357
+ this.sessionReport.summary.merged = this.sessionReport.modifications.filter(
358
+ (m) => m.phase === 'merged',
359
+ ).length
360
+
361
+ return {
362
+ success: true,
363
+ message: `Successfully merged ${this.sessionReport.summary.merged} modification(s).`,
364
+ }
365
+ } catch (err) {
366
+ return { success: false, message: `Merge failed: ${String(err)}` }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Stage 5-alt: Roll back all modifications.
372
+ *
373
+ * Discards the worktree and all changes. Called automatically when
374
+ * tests fail, or manually by the user if they reject the diff.
375
+ */
376
+ rollback(): { success: boolean; message: string } {
377
+ if (!this.worktreePath || !this.worktreeBranch) {
378
+ return { success: false, message: 'No worktree to roll back.' }
379
+ }
380
+
381
+ // Mark all modifications as rolled-back
382
+ for (const modResult of this.sessionReport.modifications) {
383
+ if (modResult.phase !== 'merged') {
384
+ modResult.phase = 'rolled-back'
385
+ }
386
+ }
387
+ this.sessionReport.summary.rolledBack = this.sessionReport.modifications.filter(
388
+ (m) => m.phase === 'rolled-back',
389
+ ).length
390
+
391
+ // Remove the worktree
392
+ this.removeWorktree()
393
+
394
+ return {
395
+ success: true,
396
+ message: `Rolled back ${this.sessionReport.summary.rolledBack} modification(s).`,
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Clean up: remove the worktree and its branch.
402
+ * Safe to call at any time — does nothing if no worktree exists.
403
+ */
404
+ removeWorktree(): void {
405
+ if (!this.worktreePath || !this.worktreeBranch) return
406
+
407
+ try {
408
+ // Remove worktree
409
+ execSync(`git worktree remove --force "${this.worktreePath}"`, {
410
+ cwd: this.repoRoot,
411
+ timeout: 15_000,
412
+ })
413
+ } catch {
414
+ // Best-effort: if worktree remove fails, try manual cleanup
415
+ try {
416
+ if (existsSync(this.worktreePath)) {
417
+ rmSync(this.worktreePath, { recursive: true, force: true })
418
+ }
419
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 10_000 })
420
+ } catch {
421
+ // Give up — stale worktrees will be cleaned up on next createWorktree()
422
+ }
423
+ }
424
+
425
+ // Delete the branch
426
+ try {
427
+ execSync(`git branch -D "${this.worktreeBranch}"`, {
428
+ cwd: this.repoRoot,
429
+ timeout: 10_000,
430
+ })
431
+ } catch {
432
+ // Branch may already be gone
433
+ }
434
+
435
+ this.worktreePath = undefined
436
+ this.worktreeBranch = undefined
437
+ }
438
+
439
+ /**
440
+ * Generate and persist the session report.
441
+ */
442
+ finalize(): CrsiSessionReport {
443
+ this.sessionReport.completedAt = new Date().toISOString()
444
+
445
+ // Persist to ~/.mipham/crsi-sandbox/
446
+ const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
447
+ try {
448
+ writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
449
+ } catch {
450
+ // Best-effort persistence
451
+ }
452
+
453
+ // Clean up worktree
454
+ this.removeWorktree()
455
+
456
+ return this.sessionReport
457
+ }
458
+
459
+ /**
460
+ * Get the current session report (for live status checks).
461
+ */
462
+ getReport(): CrsiSessionReport {
463
+ return this.sessionReport
464
+ }
465
+
466
+ /**
467
+ * Load a historical session report.
468
+ */
469
+ static loadReport(sessionId: string): CrsiSessionReport | null {
470
+ const reportPath = join(REPORT_DIR, `${sessionId}.json`)
471
+ if (!existsSync(reportPath)) return null
472
+ try {
473
+ return JSON.parse(readFileSync(reportPath, 'utf-8'))
474
+ } catch {
475
+ return null
476
+ }
477
+ }
478
+
479
+ /**
480
+ * List all session reports.
481
+ */
482
+ static listReports(): string[] {
483
+ if (!existsSync(REPORT_DIR)) return []
484
+ try {
485
+ return readdirSync(REPORT_DIR)
486
+ .filter((f: string) => f.endsWith('.json'))
487
+ .map((f: string) => f.replace('.json', ''))
488
+ } catch {
489
+ return []
490
+ }
491
+ }
492
+
493
+ // ── Private Helpers ──
494
+
495
+ /**
496
+ * Remove stale worktrees from previous sessions that may have crashed
497
+ * before cleanup.
498
+ */
499
+ private cleanupStaleWorktrees(): void {
500
+ try {
501
+ const listOutput = execSync('git worktree list --porcelain', {
502
+ cwd: this.repoRoot,
503
+ timeout: 10_000,
504
+ encoding: 'utf-8',
505
+ })
506
+
507
+ const worktrees = this.parseWorktreeList(listOutput)
508
+
509
+ for (const wt of worktrees) {
510
+ // Only clean up CRSI sandbox worktrees
511
+ if (!wt.branch.includes(WORKTREE_PREFIX)) continue
512
+
513
+ // Check if the worktree path still exists
514
+ if (!existsSync(wt.path)) {
515
+ // Stale entry — prune it
516
+ try {
517
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 5_000 })
518
+ execSync(`git branch -D "${wt.branch}"`, { cwd: this.repoRoot, timeout: 5_000 })
519
+ } catch {
520
+ // Best-effort cleanup
521
+ }
522
+ }
523
+ }
524
+ } catch {
525
+ // Not a git repo or worktree command failed — skip cleanup
526
+ }
527
+ }
528
+
529
+ private parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
530
+ const worktrees: Array<{ path: string; branch: string }> = []
531
+ let current: { path?: string; branch?: string } = {}
532
+
533
+ for (const line of output.split('\n')) {
534
+ if (line.startsWith('worktree ')) {
535
+ if (current.path && current.branch) {
536
+ worktrees.push({ path: current.path, branch: current.branch })
537
+ }
538
+ current = { path: line.slice('worktree '.length) }
539
+ }
540
+ if (line.startsWith('branch ')) {
541
+ current.branch = line.slice('branch '.length).replace('refs/heads/', '')
542
+ }
543
+ }
544
+
545
+ if (current.path && current.branch) {
546
+ worktrees.push({ path: current.path, branch: current.branch })
547
+ }
548
+
549
+ return worktrees
550
+ }
551
+ }
@@ -14,6 +14,7 @@ import type { ArtifactServer } from '../artifacts/server'
14
14
  import type { AgentRegistry } from '../agent/agent-registry'
15
15
  import { analyzeForMemory } from './memory/memory-writer'
16
16
  import { getMemoryManager } from './memory/memory-loader'
17
+ import { AutoMemoryEngine } from './auto-memory.js'
17
18
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
18
19
  import type { SkillsLoader } from '../skills/loader'
19
20
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
@@ -45,6 +46,7 @@ export class QueryEngine {
45
46
  private ruleEngine?: ExperienceRuleEngine
46
47
  private _patternAnalyzer?: PatternAnalyzer
47
48
  private _effectivenessTracker?: EffectivenessTracker
49
+ private _autoMemory?: AutoMemoryEngine
48
50
  private goal?: string
49
51
  private maxGoalLoops = 20
50
52
  private lastAssistantContent?: string
@@ -1017,6 +1019,24 @@ export class QueryEngine {
1017
1019
  return this._effectivenessTracker
1018
1020
  }
1019
1021
 
1022
+ /**
1023
+ * CRSI Phase 2: Lazily-initialized AutoMemoryEngine singleton.
1024
+ * Wires the CRSI Phase 1 pipeline (PatternAnalyzer, RuleEngine, EffectivenessTracker)
1025
+ * into the auto-reflection engine so that post-turn insights can feed rule generation.
1026
+ */
1027
+ getAutoMemory(): AutoMemoryEngine {
1028
+ if (!this._autoMemory) {
1029
+ this._autoMemory = new AutoMemoryEngine()
1030
+ // Wire CRSI Phase 1 → Phase 2 bridge
1031
+ this._autoMemory.setCrsiPipeline(
1032
+ this.getPatternAnalyzer(),
1033
+ this.getRuleEngine() || new ExperienceRuleEngine(),
1034
+ this.getEffectivenessTracker(),
1035
+ )
1036
+ }
1037
+ return this._autoMemory
1038
+ }
1039
+
1020
1040
  /** Register a tool dynamically (used by MCP auto-registration). */
1021
1041
  registerTool(tool: ToolDefinition): void {
1022
1042
  if (this.tools.has(tool.name)) {
package/src/ui/app.tsx CHANGED
@@ -44,6 +44,8 @@ export interface ToolMeta {
44
44
  input: string
45
45
  output?: string
46
46
  collapsed: boolean
47
+ /** Original tool name before Claude Code display renaming (e.g. Write→Update). */
48
+ originalName?: string
47
49
  }
48
50
 
49
51
  export interface ChatMessage {
@@ -87,7 +89,8 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
87
89
  bypassPermissions: 'red',
88
90
  }
89
91
 
90
- /** Format a tool's input parameters into a compact one-line detail string. */
92
+ /** Format a tool's input parameters into a compact one-line detail string.
93
+ * Tool display names follow Claude Code convention: Write/Edit → Update. */
91
94
  function formatToolDetail(name: string, input: Record<string, unknown>): string {
92
95
  switch (name) {
93
96
  case 'Bash':
@@ -95,9 +98,9 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
95
98
  case 'Read':
96
99
  return (input.file_path as string) || ''
97
100
  case 'Write':
98
- return (input.file_path as string) || ''
101
+ return (input.file_path as string) || '' // displayed as "Update" in chat
99
102
  case 'Edit':
100
- return (input.file_path as string) || ''
103
+ return `${(input.file_path as string) || ''}: ${((input.old_string as string) || '').slice(0, 60)}` // displayed as "Update" in chat
101
104
  case 'Grep':
102
105
  return (input.pattern as string) || ''
103
106
  case 'Glob':
@@ -115,6 +118,12 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
115
118
  }
116
119
  }
117
120
 
121
+ /** Claude Code display name: maps Write/Edit → Update for parity. */
122
+ function toolDisplayName(name: string): string {
123
+ if (name === 'Write' || name === 'Edit') return 'Update'
124
+ return name
125
+ }
126
+
118
127
  export function App({
119
128
  engine,
120
129
  config,
@@ -462,7 +471,12 @@ export function App({
462
471
  {
463
472
  role: 'system' as const,
464
473
  content: detail,
465
- toolMeta: { name: toolName, input: detail, collapsed: true },
474
+ toolMeta: {
475
+ name: toolDisplayName(toolName),
476
+ input: detail,
477
+ collapsed: true,
478
+ originalName: toolName,
479
+ },
466
480
  },
467
481
  ])
468
482
  // Flush stream buffer before showing tool card
@@ -475,18 +489,35 @@ export function App({
475
489
  setAgentProgress(null)
476
490
  // Sync background agents (e.g. Agent tool may have spawned them)
477
491
  syncBgAgents()
478
- // Only show tool results with meaningful content.
479
- // Skip empty results, "(no matches)", and other noise.
480
492
  const output = chunk.content ? String(chunk.content).trim() : ''
481
493
  if (output && output !== '(no matches)' && output.length > 20) {
482
- const firstLine = output.split('\n')[0]!.slice(0, 200)
483
- const preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
494
+ // Generate diff-style summary for file operations (Claude Code parity)
495
+ let preview: string
496
+ const isFileOp = chunk.toolUse?.name === 'Write' || chunk.toolUse?.name === 'Edit'
497
+ if (isFileOp && chunk.toolUse?.input?.file_path) {
498
+ const filePath = String(chunk.toolUse.input.file_path)
499
+ const newContent = String(
500
+ chunk.toolUse.input.content || chunk.toolUse.input.new_string || '',
501
+ )
502
+ // Count lines added/removed for Write operations
503
+ const lineCount = newContent.split('\n').length
504
+ preview = `Updated ${filePath} · ${lineCount} lines`
505
+ } else {
506
+ const firstLine = output.split('\n')[0]!.slice(0, 200)
507
+ preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
508
+ }
484
509
  setMessages((prev) => [
485
510
  ...prev,
486
511
  {
487
512
  role: 'system' as const,
488
- content: ` ⎿ ${preview}`,
489
- toolMeta: { name: '', input: '', output: preview, collapsed: true },
513
+ content: preview,
514
+ toolMeta: {
515
+ name: '',
516
+ input: '',
517
+ output: preview,
518
+ collapsed: true,
519
+ originalName: chunk.toolUse?.name,
520
+ },
490
521
  },
491
522
  ])
492
523
  }