lonny-agent 0.2.3 → 0.2.6

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.
Files changed (129) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/llm.d.ts +4 -0
  5. package/dist/agent/llm.d.ts.map +1 -1
  6. package/dist/agent/project.d.ts +26 -0
  7. package/dist/agent/project.d.ts.map +1 -0
  8. package/dist/agent/project.js +303 -0
  9. package/dist/agent/project.js.map +1 -0
  10. package/dist/agent/prompt-builder.d.ts +1 -1
  11. package/dist/agent/prompt-builder.d.ts.map +1 -1
  12. package/dist/agent/prompt-builder.js +22 -13
  13. package/dist/agent/prompt-builder.js.map +1 -1
  14. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  15. package/dist/agent/providers/anthropic.js +20 -2
  16. package/dist/agent/providers/anthropic.js.map +1 -1
  17. package/dist/agent/providers/google.d.ts.map +1 -1
  18. package/dist/agent/providers/google.js +1 -1
  19. package/dist/agent/providers/google.js.map +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts.map +1 -1
  24. package/dist/agent/providers/openai.js +63 -3
  25. package/dist/agent/providers/openai.js.map +1 -1
  26. package/dist/agent/session.d.ts +8 -2
  27. package/dist/agent/session.d.ts.map +1 -1
  28. package/dist/agent/session.js +250 -153
  29. package/dist/agent/session.js.map +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/index.js +7 -1
  32. package/dist/config/index.js.map +1 -1
  33. package/dist/tools/__tests__/bash.test.js +1 -1
  34. package/dist/tools/__tests__/bash.test.js.map +1 -1
  35. package/dist/tools/__tests__/edit.test.js +875 -371
  36. package/dist/tools/__tests__/edit.test.js.map +1 -1
  37. package/dist/tools/__tests__/grep.test.js +32 -0
  38. package/dist/tools/__tests__/grep.test.js.map +1 -1
  39. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  40. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  41. package/dist/tools/__tests__/sed.test.js +228 -0
  42. package/dist/tools/__tests__/sed.test.js.map +1 -0
  43. package/dist/tools/bash.d.ts.map +1 -1
  44. package/dist/tools/bash.js +83 -10
  45. package/dist/tools/bash.js.map +1 -1
  46. package/dist/tools/edit.d.ts +58 -0
  47. package/dist/tools/edit.d.ts.map +1 -1
  48. package/dist/tools/edit.js +366 -149
  49. package/dist/tools/edit.js.map +1 -1
  50. package/dist/tools/grep.d.ts.map +1 -1
  51. package/dist/tools/grep.js +54 -30
  52. package/dist/tools/grep.js.map +1 -1
  53. package/dist/tools/read.d.ts +6 -0
  54. package/dist/tools/read.d.ts.map +1 -1
  55. package/dist/tools/read.js +42 -15
  56. package/dist/tools/read.js.map +1 -1
  57. package/dist/tools/registry.d.ts +0 -6
  58. package/dist/tools/registry.d.ts.map +1 -1
  59. package/dist/tools/registry.js +2 -33
  60. package/dist/tools/registry.js.map +1 -1
  61. package/dist/tools/sed.d.ts +4 -0
  62. package/dist/tools/sed.d.ts.map +1 -0
  63. package/dist/tools/sed.js +121 -0
  64. package/dist/tools/sed.js.map +1 -0
  65. package/dist/tools/write_plan.d.ts +1 -1
  66. package/dist/tools/write_plan.d.ts.map +1 -1
  67. package/dist/tools/write_plan.js +14 -4
  68. package/dist/tools/write_plan.js.map +1 -1
  69. package/dist/tui/index.d.ts.map +1 -1
  70. package/dist/tui/index.js +5 -4
  71. package/dist/tui/index.js.map +1 -1
  72. package/dist/web/index.d.ts.map +1 -1
  73. package/dist/web/index.js +30 -7
  74. package/dist/web/index.js.map +1 -1
  75. package/dist/web/public/app.js +5 -855
  76. package/dist/web/public/confirm.js +70 -0
  77. package/dist/web/public/index.html +121 -120
  78. package/dist/web/public/input.js +107 -0
  79. package/dist/web/public/messages.js +395 -0
  80. package/dist/web/public/sidebar.js +82 -0
  81. package/dist/web/public/state.js +72 -0
  82. package/dist/web/public/style.css +996 -949
  83. package/dist/web/public/utils.js +37 -0
  84. package/dist/web/public/websocket.js +267 -0
  85. package/dist/web/public/ws.js +19 -0
  86. package/dist/web/session-bridge.d.ts.map +1 -1
  87. package/dist/web/session-bridge.js +3 -7
  88. package/dist/web/session-bridge.js.map +1 -1
  89. package/package.json +55 -54
  90. package/scripts/copy-native.mjs +24 -0
  91. package/scripts/copy-web.mjs +15 -0
  92. package/src/agent/index.ts +25 -25
  93. package/src/agent/llm.ts +4 -0
  94. package/src/agent/project.ts +366 -0
  95. package/src/agent/prompt-builder.ts +23 -13
  96. package/src/agent/providers/anthropic.ts +179 -172
  97. package/src/agent/providers/google.ts +210 -211
  98. package/src/agent/providers/ollama.ts +186 -182
  99. package/src/agent/providers/openai.ts +73 -5
  100. package/src/agent/session.ts +275 -153
  101. package/src/config/index.ts +342 -335
  102. package/src/tools/__tests__/bash.test.ts +1 -1
  103. package/src/tools/__tests__/edit.test.ts +981 -385
  104. package/src/tools/__tests__/grep.test.ts +35 -0
  105. package/src/tools/bash.ts +97 -11
  106. package/src/tools/edit.ts +414 -154
  107. package/src/tools/git.ts +76 -76
  108. package/src/tools/grep.ts +57 -30
  109. package/src/tools/read.ts +61 -25
  110. package/src/tools/registry.ts +231 -265
  111. package/src/tools/write_plan.ts +21 -5
  112. package/src/tui/index.ts +786 -785
  113. package/src/web/index.ts +29 -7
  114. package/src/web/public/app.js +5 -855
  115. package/src/web/public/confirm.js +70 -0
  116. package/src/web/public/index.html +121 -120
  117. package/src/web/public/input.js +107 -0
  118. package/src/web/public/messages.js +395 -0
  119. package/src/web/public/sidebar.js +82 -0
  120. package/src/web/public/state.js +72 -0
  121. package/src/web/public/style.css +996 -949
  122. package/src/web/public/utils.js +37 -0
  123. package/src/web/public/websocket.js +267 -0
  124. package/src/web/public/ws.js +19 -0
  125. package/src/web/session-bridge.ts +193 -194
  126. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  127. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  128. package/.lonny/plan-web-cwd-status.md +0 -38
  129. package/src/tools/exec.ts +0 -348
@@ -5,6 +5,7 @@ import * as path from 'node:path'
5
5
  import type { Config } from '../config/index.js'
6
6
  import { saveTokenUsage } from '../config/tokens.js'
7
7
  import { FileReadTracker } from '../diff/apply.js'
8
+ import { fmtErr } from '../tools/errors.js'
8
9
  import { ToolRegistry } from '../tools/registry.js'
9
10
  import type { ToolCall, ToolResult } from '../tools/types.js'
10
11
  import { compact, estimateMessagesTokens, shouldCompact } from './compaction.js'
@@ -24,6 +25,8 @@ interface SessionData {
24
25
  totalInputTokens: number
25
26
  totalOutputTokens: number
26
27
  totalApiCalls: number
28
+ totalCacheHitTokens?: number
29
+ totalCacheMissTokens?: number
27
30
  mode: 'code' | 'plan' | 'ask'
28
31
  model: string
29
32
  provider: string
@@ -221,13 +224,36 @@ function printTokenStats(
221
224
  turnApi: number,
222
225
  totalApi: number,
223
226
  output?: SessionOutput,
227
+ turnCacheHit?: number,
228
+ turnCacheMiss?: number,
229
+ totalCacheHit?: number,
230
+ totalCacheMiss?: number,
224
231
  ): void {
225
232
  const bus = getGlobalEventBus()
226
- bus.emit(EventChannels.TOKEN_STATS, { turnIn, turnOut, totalIn, totalOut, turnApi, totalApi })
233
+ bus.emit(EventChannels.TOKEN_STATS, {
234
+ turnIn,
235
+ turnOut,
236
+ totalIn,
237
+ totalOut,
238
+ turnApi,
239
+ totalApi,
240
+ turnCacheHit,
241
+ turnCacheMiss,
242
+ totalCacheHit,
243
+ totalCacheMiss,
244
+ })
227
245
  // Skip terminal output in Web UI mode
228
246
  if (output?.suppressToolOutput) return
229
247
  const total = totalIn + totalOut
230
- const msg = ` ${GY}┃${RS} ${GY}${BLD}▴${RS}${GY}${turnIn}${RS} ${GY}${BLD}▾${RS}${GY}${turnOut}${RS} ${GY}total${RS} ${total} ${GY}calls${RS} ${turnApi}(${totalApi})`
248
+ let msg = ` ${GY}┃${RS} ${GY}${BLD}▴${RS}${GY}${turnIn}${RS} ${GY}${BLD}▾${RS}${GY}${turnOut}${RS} ${GY}total${RS} ${total} ${GY}calls${RS} ${turnApi}(${totalApi})`
249
+ // Show cache hit rate if available
250
+ const cacheHit = totalCacheHit ?? 0
251
+ const cacheMiss = totalCacheMiss ?? 0
252
+ const cacheTotal = cacheHit + cacheMiss
253
+ if (cacheTotal > 0) {
254
+ const pct = Math.round((cacheHit / cacheTotal) * 100)
255
+ msg += ` ${GY}cached${RS} ${pct}%`
256
+ }
231
257
  writeOut(`\n${msg}\n`, output)
232
258
  }
233
259
 
@@ -258,6 +284,10 @@ export class Session {
258
284
  turnOutputTokens: number = 0
259
285
  turnApiCalls: number = 0
260
286
  totalApiCalls: number = 0
287
+ turnCacheHitTokens: number = 0
288
+ turnCacheMissTokens: number = 0
289
+ totalCacheHitTokens: number = 0
290
+ totalCacheMissTokens: number = 0
261
291
  private stopped: boolean = false
262
292
  private abortController: AbortController | null = null
263
293
 
@@ -291,7 +321,17 @@ export class Session {
291
321
  this.provider = new AnthropicProvider(config.apiKey, config.baseUrl, config.model)
292
322
  }
293
323
 
294
- this.messages = [{ role: 'system', content: buildSystemPrompt(config) }]
324
+ // Placeholder until initSystemPrompt() is called
325
+ this.messages = [{ role: 'system', content: '' }]
326
+ // Initialize system prompt asynchronously
327
+ this.initSystemPrompt(config)
328
+ }
329
+
330
+ /** Initialize the system prompt asynchronously */
331
+ private initSystemPrompt(config: Config): void {
332
+ buildSystemPrompt(config).then(prompt => {
333
+ this.messages = [{ role: 'system', content: prompt }]
334
+ })
295
335
  }
296
336
 
297
337
  /** Persist the current session to ~/.lonny/sessions/ */
@@ -305,6 +345,8 @@ export class Session {
305
345
  totalInputTokens: this.totalInputTokens,
306
346
  totalOutputTokens: this.totalOutputTokens,
307
347
  totalApiCalls: this.totalApiCalls,
348
+ totalCacheHitTokens: this.totalCacheHitTokens || undefined,
349
+ totalCacheMissTokens: this.totalCacheMissTokens || undefined,
308
350
  mode: this.config.mode,
309
351
  model: this.config.model,
310
352
  provider: this.config.provider,
@@ -314,7 +356,7 @@ export class Session {
314
356
  }
315
357
 
316
358
  /** Try to load a saved session for the given cwd. Returns null if none exists. */
317
- static load(config: Config, output?: SessionOutput): Session | null {
359
+ static async load(config: Config, output?: SessionOutput): Promise<Session | null> {
318
360
  const filePath = getSessionFilePath(config.cwd)
319
361
  let data: SessionData
320
362
  try {
@@ -340,12 +382,15 @@ export class Session {
340
382
  data.provider !== config.provider ||
341
383
  data.mode !== config.mode
342
384
  ) {
343
- session.messages[0] = { role: 'system', content: buildSystemPrompt(config) }
385
+ const prompt = await buildSystemPrompt(config)
386
+ session.messages[0] = { role: 'system', content: prompt }
344
387
  }
345
388
  // Restore token stats
346
389
  session.totalInputTokens = data.totalInputTokens
347
390
  session.totalOutputTokens = data.totalOutputTokens
348
391
  session.totalApiCalls = data.totalApiCalls
392
+ session.totalCacheHitTokens = data.totalCacheHitTokens ?? 0
393
+ session.totalCacheMissTokens = data.totalCacheMissTokens ?? 0
349
394
  return session
350
395
  }
351
396
 
@@ -359,9 +404,10 @@ export class Session {
359
404
  }
360
405
  }
361
406
 
362
- setMode(mode: 'code' | 'plan' | 'ask'): void {
407
+ async setMode(mode: 'code' | 'plan' | 'ask'): Promise<void> {
363
408
  this.config.mode = mode
364
- this.messages[0] = { role: 'system', content: buildSystemPrompt(this.config) }
409
+ const prompt = await buildSystemPrompt(this.config)
410
+ this.messages[0] = { role: 'system', content: prompt }
365
411
  this.registry.setMode(mode)
366
412
  this.save()
367
413
  }
@@ -381,6 +427,8 @@ export class Session {
381
427
  /** Reset the stopped flag for a new conversation */
382
428
  resetStopped(): void {
383
429
  this.stopped = false
430
+ // Create new AbortController for next conversation
431
+ this.abortController = new AbortController()
384
432
  }
385
433
 
386
434
  async chat(userPrompt: string): Promise<void> {
@@ -395,6 +443,8 @@ export class Session {
395
443
  this.turnInputTokens = 0
396
444
  this.turnOutputTokens = 0
397
445
  this.turnApiCalls = 0
446
+ this.turnCacheHitTokens = 0
447
+ this.turnCacheMissTokens = 0
398
448
 
399
449
  let iterations = 0
400
450
  const maxIterations = 30
@@ -433,171 +483,224 @@ export class Session {
433
483
  this.abortController.signal,
434
484
  )
435
485
 
436
- for await (const chunk of stream) {
437
- if (chunk.reasoning_content) {
438
- reasoningContent = chunk.reasoning_content
439
- // Stream reasoning content in real-time (only when no text in same chunk)
440
- if (!chunk.text) {
441
- // Emit thinking via EventBus for Web UI
442
- bus.emit(EventChannels.THINKING, { text: chunk.reasoning_content })
443
- if (!reasoningOutput) {
444
- reasoningOutput = true
445
- reasoningLineStart = true
446
- if (!out?.suppressToolOutput) {
447
- writeOut(thinkTopBorder(), out)
448
- }
449
- }
450
- // Terminal display with box drawing (skip in Web UI mode, handled by EventBus)
451
- if (!out?.suppressToolOutput) {
452
- // Track column position on current line for wrapping
453
- let thinkCol = 0
454
- // Handle newlines in streamed content - add left border on each new line
455
- // Also manually wrap long lines so wrapped lines keep the │ prefix.
456
- let remaining = chunk.reasoning_content
457
- const maxContentWidth = termWidth() - THINK_PREFIX_WIDTH
458
- while (remaining.length > 0) {
459
- if (reasoningLineStart) {
460
- writeOut(` ${GY}│${RS}${TH}`, out)
461
- reasoningLineStart = false
462
- thinkCol = 0
486
+ try {
487
+ for await (const chunk of stream) {
488
+ if (chunk.reasoning_content) {
489
+ reasoningContent = chunk.reasoning_content
490
+ // Stream reasoning content in real-time (only when no text in same chunk)
491
+ if (!chunk.text) {
492
+ // Emit thinking via EventBus for Web UI
493
+ bus.emit(EventChannels.THINKING, { text: chunk.reasoning_content })
494
+ if (!reasoningOutput) {
495
+ reasoningOutput = true
496
+ reasoningLineStart = true
497
+ if (!out?.suppressToolOutput) {
498
+ writeOut(thinkTopBorder(), out)
463
499
  }
464
- const nlIdx = remaining.indexOf('\n')
465
- if (nlIdx === -1) {
466
- // No newline — write as much as fits on current line, wrap if needed
467
- while (remaining.length > 0) {
468
- const segWidth = visibleWidth(remaining)
500
+ }
501
+ // Terminal display with box drawing (skip in Web UI mode, handled by EventBus)
502
+ if (!out?.suppressToolOutput) {
503
+ // Track column position on current line for wrapping
504
+ let thinkCol = 0
505
+ // Handle newlines in streamed content - add left border on each new line
506
+ // Also manually wrap long lines so wrapped lines keep the │ prefix.
507
+ let remaining = chunk.reasoning_content
508
+ const maxContentWidth = termWidth() - THINK_PREFIX_WIDTH
509
+ while (remaining.length > 0) {
510
+ if (reasoningLineStart) {
511
+ writeOut(` ${GY}│${RS}${TH}`, out)
512
+ reasoningLineStart = false
513
+ thinkCol = 0
514
+ }
515
+ const nlIdx = remaining.indexOf('\n')
516
+ if (nlIdx === -1) {
517
+ // No newline — write as much as fits on current line, wrap if needed
518
+ while (remaining.length > 0) {
519
+ const segWidth = visibleWidth(remaining)
520
+ const avail = maxContentWidth - thinkCol
521
+ if (segWidth <= avail) {
522
+ // Fits entirely on current line
523
+ writeOut(remaining, out)
524
+ thinkCol += segWidth
525
+ remaining = ''
526
+ } else if (avail <= 0) {
527
+ // Current line is full, wrap to next
528
+ writeOut(`${RS}\n`, out)
529
+ writeOut(` ${GY}│${RS}${TH}`, out)
530
+ thinkCol = 0
531
+ } else {
532
+ // Write first part that fits, then wrap
533
+ // Find character boundary that fits within avail
534
+ let cut = avail
535
+ while (cut > 0 && visibleWidth(remaining.slice(0, cut)) > avail) cut--
536
+ if (cut <= 0) cut = 1
537
+ writeOut(remaining.slice(0, cut), out)
538
+ writeOut(`${RS}\n`, out)
539
+ writeOut(` ${GY}│${RS}${TH}`, out)
540
+ thinkCol = 0
541
+ remaining = remaining.slice(cut)
542
+ }
543
+ }
544
+ } else {
545
+ // Has newline — process the segment up to newline
546
+ const segment = remaining.slice(0, nlIdx)
547
+ const segWidth = visibleWidth(segment)
469
548
  const avail = maxContentWidth - thinkCol
470
549
  if (segWidth <= avail) {
471
- // Fits entirely on current line
472
- writeOut(remaining, out)
473
- thinkCol += segWidth
474
- remaining = ''
475
- } else if (avail <= 0) {
476
- // Current line is full, wrap to next
550
+ // Segment fits on current line
551
+ writeOut(segment, out)
477
552
  writeOut(`${RS}\n`, out)
478
- writeOut(` ${GY}│${RS}${TH}`, out)
553
+ reasoningLineStart = true
479
554
  thinkCol = 0
480
555
  } else {
481
- // Write first part that fits, then wrap
482
- // Find character boundary that fits within avail
483
- let cut = avail
484
- while (cut > 0 && visibleWidth(remaining.slice(0, cut)) > avail) cut--
485
- if (cut <= 0) cut = 1
486
- writeOut(remaining.slice(0, cut), out)
556
+ // Segment too long write what fits, wrap, then rest
557
+ let rest = segment
558
+ // Write remainder of current line
559
+ if (avail > 0) {
560
+ let cut = avail
561
+ while (cut > 0 && visibleWidth(rest.slice(0, cut)) > avail) cut--
562
+ if (cut <= 0) cut = 1
563
+ writeOut(rest.slice(0, cut), out)
564
+ rest = rest.slice(cut)
565
+ }
487
566
  writeOut(`${RS}\n`, out)
488
- writeOut(` ${GY}│${RS}${TH}`, out)
567
+ reasoningLineStart = true
489
568
  thinkCol = 0
490
- remaining = remaining.slice(cut)
491
- }
492
- }
493
- } else {
494
- // Has newline process the segment up to newline
495
- const segment = remaining.slice(0, nlIdx)
496
- const segWidth = visibleWidth(segment)
497
- const avail = maxContentWidth - thinkCol
498
- if (segWidth <= avail) {
499
- // Segment fits on current line
500
- writeOut(segment, out)
501
- writeOut(`${RS}\n`, out)
502
- reasoningLineStart = true
503
- thinkCol = 0
504
- } else {
505
- // Segment too long — write what fits, wrap, then rest
506
- let rest = segment
507
- // Write remainder of current line
508
- if (avail > 0) {
509
- let cut = avail
510
- while (cut > 0 && visibleWidth(rest.slice(0, cut)) > avail) cut--
511
- if (cut <= 0) cut = 1
512
- writeOut(rest.slice(0, cut), out)
513
- rest = rest.slice(cut)
514
- }
515
- writeOut(`${RS}\n`, out)
516
- reasoningLineStart = true
517
- thinkCol = 0
518
- // Write rest of segment on continuation line(s)
519
- if (rest.length > 0) {
520
- writeOut(` ${GY}│${RS}${TH}`, out)
521
- reasoningLineStart = false
522
- while (rest.length > 0) {
523
- const rw = visibleWidth(rest)
524
- if (rw <= maxContentWidth) {
525
- writeOut(rest, out)
526
- thinkCol = rw
527
- rest = ''
528
- } else {
529
- let cut = maxContentWidth
530
- while (cut > 0 && visibleWidth(rest.slice(0, cut)) > maxContentWidth)
531
- cut--
532
- if (cut <= 0) cut = 1
533
- writeOut(rest.slice(0, cut), out)
534
- writeOut(`${RS}\n`, out)
535
- writeOut(` ${GY}│${RS}${TH}`, out)
536
- rest = rest.slice(cut)
569
+ // Write rest of segment on continuation line(s)
570
+ if (rest.length > 0) {
571
+ writeOut(` ${GY}│${RS}${TH}`, out)
572
+ reasoningLineStart = false
573
+ while (rest.length > 0) {
574
+ const rw = visibleWidth(rest)
575
+ if (rw <= maxContentWidth) {
576
+ writeOut(rest, out)
577
+ thinkCol = rw
578
+ rest = ''
579
+ } else {
580
+ let cut = maxContentWidth
581
+ while (cut > 0 && visibleWidth(rest.slice(0, cut)) > maxContentWidth)
582
+ cut--
583
+ if (cut <= 0) cut = 1
584
+ writeOut(rest.slice(0, cut), out)
585
+ writeOut(`${RS}\n`, out)
586
+ writeOut(` ${GY}│${RS}${TH}`, out)
587
+ rest = rest.slice(cut)
588
+ }
537
589
  }
538
590
  }
591
+ writeOut(`${RS}\n`, out)
592
+ reasoningLineStart = true
593
+ thinkCol = 0
539
594
  }
540
- writeOut(`${RS}\n`, out)
541
- reasoningLineStart = true
542
- thinkCol = 0
595
+ remaining = remaining.slice(nlIdx + 1)
543
596
  }
544
- remaining = remaining.slice(nlIdx + 1)
545
597
  }
546
598
  }
547
599
  }
548
600
  }
549
- }
550
- if (chunk.type === 'text' && chunk.text) {
551
- if (reasoningOutput) {
552
- bus.emit(EventChannels.THINKING_END, {})
553
- if (!out?.suppressToolOutput) {
554
- writeOut(`${RS}\n`, out)
555
- writeOut(thinkBottomBorder(), out)
601
+ if (chunk.type === 'text' && chunk.text) {
602
+ if (reasoningOutput) {
603
+ bus.emit(EventChannels.THINKING_END, {})
604
+ if (!out?.suppressToolOutput) {
605
+ writeOut(`${RS}\n`, out)
606
+ writeOut(thinkBottomBorder(), out)
607
+ }
608
+ reasoningOutput = false
609
+ reasoningLineStart = false
556
610
  }
557
- reasoningOutput = false
558
- reasoningLineStart = false
559
- }
560
- fullResponse += chunk.text
561
- writeOut(chunk.text, out)
562
- } else if (chunk.type === 'tool_use' && chunk.tool_call) {
563
- toolCalls.push(chunk.tool_call)
564
- } else if (chunk.type === 'complete') {
565
- if (chunk.usage) {
566
- this.turnInputTokens += chunk.usage.input_tokens
567
- this.turnOutputTokens += chunk.usage.output_tokens
568
- this.totalInputTokens += chunk.usage.input_tokens
569
- this.totalOutputTokens += chunk.usage.output_tokens
570
- }
571
- if (chunk.finish_reason === 'stop' || chunk.finish_reason === 'end_turn') {
572
- if (toolCalls.length === 0) {
573
- const finalAssistantMsg: LLMMessage = {
574
- role: 'assistant',
575
- content: fullResponse || null,
576
- reasoning_content: reasoningContent,
611
+ fullResponse += chunk.text
612
+ writeOut(chunk.text, out)
613
+ } else if (chunk.type === 'tool_use' && chunk.tool_call) {
614
+ toolCalls.push(chunk.tool_call)
615
+ } else if (chunk.type === 'complete') {
616
+ if (chunk.usage) {
617
+ this.turnInputTokens += chunk.usage.input_tokens
618
+ this.turnOutputTokens += chunk.usage.output_tokens
619
+ this.totalInputTokens += chunk.usage.input_tokens
620
+ this.totalOutputTokens += chunk.usage.output_tokens
621
+ // Accumulate DeepSeek cache hit/miss tokens
622
+ if (chunk.usage.prompt_cache_hit_tokens != null) {
623
+ this.turnCacheHitTokens += chunk.usage.prompt_cache_hit_tokens
624
+ this.totalCacheHitTokens += chunk.usage.prompt_cache_hit_tokens
625
+ }
626
+ if (chunk.usage.prompt_cache_miss_tokens != null) {
627
+ this.turnCacheMissTokens += chunk.usage.prompt_cache_miss_tokens
628
+ this.totalCacheMissTokens += chunk.usage.prompt_cache_miss_tokens
577
629
  }
578
- this.messages.push(finalAssistantMsg)
579
- printTokenStats(
580
- this.turnInputTokens,
581
- this.turnOutputTokens,
582
- this.totalInputTokens,
583
- this.totalOutputTokens,
584
- this.turnApiCalls,
585
- this.totalApiCalls,
586
- out,
587
- )
588
- writeOut('\n\n', out)
589
- saveTokenUsage(
590
- this.config.cwd,
591
- this.turnInputTokens,
592
- this.turnOutputTokens,
593
- this.turnApiCalls,
594
- )
595
- bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
596
- this.save()
597
- return
598
630
  }
631
+ if (chunk.finish_reason === 'stop' || chunk.finish_reason === 'end_turn') {
632
+ if (toolCalls.length === 0) {
633
+ const finalAssistantMsg: LLMMessage = {
634
+ role: 'assistant',
635
+ content: fullResponse || null,
636
+ reasoning_content: reasoningContent,
637
+ }
638
+ this.messages.push(finalAssistantMsg)
639
+ printTokenStats(
640
+ this.turnInputTokens,
641
+ this.turnOutputTokens,
642
+ this.totalInputTokens,
643
+ this.totalOutputTokens,
644
+ this.turnApiCalls,
645
+ this.totalApiCalls,
646
+ out,
647
+ this.turnCacheHitTokens,
648
+ this.turnCacheMissTokens,
649
+ this.totalCacheHitTokens,
650
+ this.totalCacheMissTokens,
651
+ )
652
+ writeOut('\n\n', out)
653
+ saveTokenUsage(
654
+ this.config.cwd,
655
+ this.turnInputTokens,
656
+ this.turnOutputTokens,
657
+ this.turnApiCalls,
658
+ )
659
+ bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
660
+ this.save()
661
+ return
662
+ }
663
+ }
664
+ }
665
+ }
666
+ } catch (e) {
667
+ const errMsg = fmtErr(e)
668
+ const partialContent = fullResponse ? fullResponse.slice(0, 500) : '(empty)'
669
+ if (!out?.suppressToolOutput) {
670
+ writeOut(`\n${RE}Stream error:${RS} ${errMsg}`, out)
671
+ writeOut(`\n ${GY}┃${RS} Partial response: ${partialContent}\n`, out)
672
+ }
673
+ console.error('[session] Stream error:', errMsg, '| Partial response:', partialContent)
674
+ if (reasoningOutput) {
675
+ bus.emit(EventChannels.THINKING_END, {})
676
+ if (!out?.suppressToolOutput) {
677
+ writeOut(`${RS}\n`, out)
678
+ writeOut(thinkBottomBorder(), out)
599
679
  }
600
680
  }
681
+ bus.emit(EventChannels.LLM_STREAM_END, { iteration: iterations, toolCallCount: 0 })
682
+ bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
683
+
684
+ // If there are pending toolCalls that weren't executed due to abort,
685
+ // add an assistant message so the model knows they weren't executed
686
+ if (toolCalls.length > 0) {
687
+ const interruptedMsg: LLMMessage = {
688
+ role: 'assistant',
689
+ content: null,
690
+ tool_calls: toolCalls,
691
+ reasoning_content: reasoningContent,
692
+ }
693
+ this.messages.push(interruptedMsg)
694
+ }
695
+
696
+ saveTokenUsage(
697
+ this.config.cwd,
698
+ this.turnInputTokens,
699
+ this.turnOutputTokens,
700
+ this.turnApiCalls,
701
+ )
702
+ this.save()
703
+ return
601
704
  }
602
705
 
603
706
  bus.emit(EventChannels.LLM_STREAM_END, {
@@ -632,6 +735,10 @@ export class Session {
632
735
  this.turnApiCalls,
633
736
  this.totalApiCalls,
634
737
  out,
738
+ this.turnCacheHitTokens,
739
+ this.turnCacheMissTokens,
740
+ this.totalCacheHitTokens,
741
+ this.totalCacheMissTokens,
635
742
  )
636
743
  writeOut('\n\n', out)
637
744
  }
@@ -657,7 +764,7 @@ export class Session {
657
764
 
658
765
  // ── User confirmation for write-type tool calls ──
659
766
  if (!this.config.autoApprove && this.output?.confirmTool && toolCalls.length > 0) {
660
- const writeTools = ['edit', 'bash', 'write_plan', 'exec', 'install_skill']
767
+ const writeTools = ['edit', 'bash', 'write_plan', 'install_skill']
661
768
  const needsConfirm = toolCalls.filter(tc => writeTools.includes(tc.name))
662
769
  if (needsConfirm.length > 0) {
663
770
  const approved = await this.output.confirmTool(needsConfirm)
@@ -676,9 +783,20 @@ export class Session {
676
783
  }
677
784
  }
678
785
 
679
- for (const tc of toolCalls) {
786
+ for (let i = 0; i < toolCalls.length; i++) {
787
+ const tc = toolCalls[i]
680
788
  // Check if stop was requested after each tool call
681
789
  if (this.isStopped()) {
790
+ // Add assistant message with remaining tool_calls so model knows they weren't executed
791
+ const remainingToolCalls = toolCalls.slice(i)
792
+ if (remainingToolCalls.length > 0) {
793
+ const interruptedMsg: LLMMessage = {
794
+ role: 'assistant',
795
+ content: null,
796
+ tool_calls: remainingToolCalls,
797
+ }
798
+ this.messages.push(interruptedMsg)
799
+ }
682
800
  bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: toolCalls.length })
683
801
  this.save()
684
802
  return
@@ -736,6 +854,10 @@ export class Session {
736
854
  this.turnApiCalls,
737
855
  this.totalApiCalls,
738
856
  out,
857
+ this.turnCacheHitTokens,
858
+ this.turnCacheMissTokens,
859
+ this.totalCacheHitTokens,
860
+ this.totalCacheMissTokens,
739
861
  )
740
862
  writeOut('\nAgent reached maximum iterations. Stopping.\n', out)
741
863
  }