@geekbeer/minion 2.33.4 → 2.42.5

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.
@@ -63,6 +63,11 @@ function buildAllowedCommands(procMgr) {
63
63
  command: `npm install -g @geekbeer/minion@latest && powershell -Command "$pid = Get-Content '${pidFile}' -ErrorAction SilentlyContinue; if ($pid) { Get-CimInstance Win32_Process -Filter \\"ParentProcessId = $pid\\" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2; Start-Process powershell -ArgumentList '-ExecutionPolicy Bypass -WindowStyle Hidden -File \\"${startScript}\\"' -WindowStyle Hidden"`,
64
64
  deferred: true,
65
65
  }
66
+ commands['update-agent-dev'] = {
67
+ description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
68
+ command: `npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873 && powershell -Command "$pid = Get-Content '${pidFile}' -ErrorAction SilentlyContinue; if ($pid) { Get-CimInstance Win32_Process -Filter \\"ParentProcessId = $pid\\" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2; Start-Process powershell -ArgumentList '-ExecutionPolicy Bypass -WindowStyle Hidden -File \\"${startScript}\\"' -WindowStyle Hidden"`,
69
+ deferred: true,
70
+ }
66
71
  commands['status-services'] = {
67
72
  description: 'Show agent process info',
68
73
  command: `powershell -Command "$pidFile = '${pidFile}'; if (Test-Path $pidFile) { $pid = (Get-Content $pidFile -Raw).Trim(); $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue; if ($proc) { Write-Host 'minion-agent: running (PID:' $pid ')' } else { Write-Host 'minion-agent: not running (stale PID)' } } else { Write-Host 'minion-agent: not running' }"`,
@@ -78,6 +83,11 @@ function buildAllowedCommands(procMgr) {
78
83
  command: 'npm install -g @geekbeer/minion@latest && nssm restart minion-agent',
79
84
  deferred: true,
80
85
  }
86
+ commands['update-agent-dev'] = {
87
+ description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
88
+ command: 'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873 && nssm restart minion-agent',
89
+ deferred: true,
90
+ }
81
91
  commands['restart-display'] = {
82
92
  description: 'Restart TightVNC and websockify services',
83
93
  command: 'nssm restart minion-websockify',
@@ -97,6 +107,11 @@ function buildAllowedCommands(procMgr) {
97
107
  command: 'npm install -g @geekbeer/minion@latest && net stop minion-agent & net start minion-agent',
98
108
  deferred: true,
99
109
  }
110
+ commands['update-agent-dev'] = {
111
+ description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
112
+ command: 'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873 && net stop minion-agent & net start minion-agent',
113
+ deferred: true,
114
+ }
100
115
  commands['status-services'] = {
101
116
  description: 'Check status of minion agent service',
102
117
  command: 'sc query minion-agent',
@@ -3,7 +3,7 @@
3
3
  # Usage:
4
4
  # minion-cli-win setup --hq-url https://... --minion-id <UUID> --api-token <TOKEN>
5
5
  # minion-cli-win setup --setup-tunnel
6
- # minion-cli-win start | stop | restart | status | health | version | help
6
+ # minion-cli-win start | stop | restart | status | health | diagnose | version | help
7
7
 
8
8
  # Parse arguments manually to avoid issues with npm wrapper passing $args as array
9
9
  $Command = 'help'
@@ -16,7 +16,7 @@ $i = 0
16
16
  while ($i -lt $args.Count) {
17
17
  $arg = [string]$args[$i]
18
18
  switch -Regex ($arg) {
19
- '^(setup|reconfigure|start|stop|restart|status|health|version|help)$' { $Command = $arg }
19
+ '^(setup|reconfigure|start|stop|restart|status|health|diagnose|version|help)$' { $Command = $arg }
20
20
  '^(-v|--version)$' { $Command = 'version' }
21
21
  '^--hq-url$' { $i++; if ($i -lt $args.Count) { $HqUrl = [string]$args[$i] } }
22
22
  '^--minion-id$' { $i++; if ($i -lt $args.Count) { $MinionId = [string]$args[$i] } }
@@ -68,6 +68,28 @@ function Test-CommandExists {
68
68
  $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
69
69
  }
70
70
 
71
+ function Get-WebsockifyCommand {
72
+ # Returns @(executable, args-prefix) for launching websockify.
73
+ # 1) websockify.exe on PATH
74
+ if (Get-Command websockify -ErrorAction SilentlyContinue) {
75
+ return @((Get-Command websockify).Source)
76
+ }
77
+ # 2) Look in Python Scripts directories
78
+ if (Test-CommandExists 'python') {
79
+ $scriptsDir = & python -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
80
+ if ($scriptsDir) {
81
+ $wsExe = Join-Path $scriptsDir 'websockify.exe'
82
+ if (Test-Path $wsExe) { return @($wsExe) }
83
+ }
84
+ }
85
+ # 3) Fallback: python -m websockify
86
+ if (Test-CommandExists 'python') {
87
+ $check = & python -c "import websockify" 2>&1
88
+ if ($LASTEXITCODE -eq 0) { return @('python', '-m', 'websockify') }
89
+ }
90
+ return $null
91
+ }
92
+
71
93
  function Get-LanIPAddress {
72
94
  try {
73
95
  $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
@@ -193,8 +215,8 @@ function Restart-MinionProcess {
193
215
  # ============================================================
194
216
 
195
217
  function Invoke-Setup {
196
- $totalSteps = 8
197
- if ($SetupTunnel) { $totalSteps = 9 }
218
+ $totalSteps = 9
219
+ if ($SetupTunnel) { $totalSteps = 10 }
198
220
 
199
221
  # Minionization warning
200
222
  Write-Host ""
@@ -311,7 +333,6 @@ function Invoke-Setup {
311
333
  New-Item -Path $LogDir -ItemType Directory -Force | Out-Null
312
334
  $envValues = @{
313
335
  'AGENT_PORT' = '8080'
314
- 'HEARTBEAT_INTERVAL' = '30'
315
336
  'MINION_USER' = $env:USERNAME
316
337
  }
317
338
  if ($HqUrl) { $envValues['HQ_URL'] = $HqUrl }
@@ -330,17 +351,21 @@ function Invoke-Setup {
330
351
 
331
352
  # Try prebuilt version first (no Build Tools required)
332
353
  try {
333
- & npm install node-pty-prebuilt-multiarch 2>$null
334
- Write-Detail "node-pty-prebuilt-multiarch installed (no Build Tools needed)"
335
- $ptyInstalled = $true
354
+ $npmResult = cmd /c "npm install node-pty-prebuilt-multiarch 2>&1"
355
+ if ($LASTEXITCODE -eq 0) {
356
+ Write-Detail "node-pty-prebuilt-multiarch installed (no Build Tools needed)"
357
+ $ptyInstalled = $true
358
+ }
336
359
  } catch {}
337
360
 
338
361
  # Fallback: source-compiled version (requires Visual Studio Build Tools)
339
362
  if (-not $ptyInstalled) {
340
363
  try {
341
- & npm install node-pty 2>$null
342
- Write-Detail "node-pty installed (compiled from source)"
343
- $ptyInstalled = $true
364
+ $npmResult = cmd /c "npm install node-pty 2>&1"
365
+ if ($LASTEXITCODE -eq 0) {
366
+ Write-Detail "node-pty installed (compiled from source)"
367
+ $ptyInstalled = $true
368
+ }
344
369
  } catch {}
345
370
  }
346
371
 
@@ -413,10 +438,17 @@ if (`$vncExe) {
413
438
  }
414
439
  # Reload registry config (ensures no-auth settings are applied)
415
440
  & `$vncExe -controlapp -reload 2>`$null
416
- if (Get-Command websockify -ErrorAction SilentlyContinue) {
441
+ `$wsCmd = Get-WebsockifyCommand
442
+ if (`$wsCmd) {
417
443
  `$wsProc = Get-Process -Name websockify -ErrorAction SilentlyContinue
418
444
  if (-not `$wsProc) {
419
- Start-Process -FilePath (Get-Command websockify).Source -ArgumentList '6080', 'localhost:5900' -WindowStyle Hidden
445
+ if (`$wsCmd.Count -eq 1) {
446
+ Start-Process -FilePath `$wsCmd[0] -ArgumentList '6080', 'localhost:5900' -WindowStyle Hidden
447
+ } else {
448
+ # python -m websockify 6080 localhost:5900
449
+ `$wsArgs = (`$wsCmd[1..(`$wsCmd.Count-1)] + @('6080', 'localhost:5900')) -join ' '
450
+ Start-Process -FilePath `$wsCmd[0] -ArgumentList `$wsArgs -WindowStyle Hidden
451
+ }
420
452
  }
421
453
  }
422
454
  }
@@ -469,8 +501,25 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
469
501
  $shortcut.Save()
470
502
  Write-Detail "Startup shortcut created: $shortcutPath"
471
503
 
472
- # Step 6: Install TightVNC Server
473
- Write-Step 6 $totalSteps "Setting up TightVNC Server..."
504
+ # Step 6: Disable screensaver, lock screen, and sleep
505
+ Write-Step 6 $totalSteps "Disabling screensaver, lock screen, and sleep..."
506
+ # Screensaver off
507
+ Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
508
+ Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
509
+ Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name SCRNSAVE.EXE -Value ''
510
+ Write-Detail "Screensaver disabled"
511
+ # Lock screen off (user-level: disable lock on resume)
512
+ Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaverIsSecure -Value '0'
513
+ Write-Detail "Lock on resume disabled"
514
+ # Power settings: never sleep, never turn off display
515
+ & powercfg -change -standby-timeout-ac 0 2>$null
516
+ & powercfg -change -standby-timeout-dc 0 2>$null
517
+ & powercfg -change -monitor-timeout-ac 0 2>$null
518
+ & powercfg -change -monitor-timeout-dc 0 2>$null
519
+ Write-Detail "Sleep and monitor timeout disabled"
520
+
521
+ # Step 7: Install TightVNC Server
522
+ Write-Step 7 $totalSteps "Setting up TightVNC Server..."
474
523
  $vncSystemPath = 'C:\Program Files\TightVNC\tvnserver.exe'
475
524
  $vncPortableDir = Join-Path $DataDir 'tightvnc'
476
525
  $vncPortablePath = Join-Path $vncPortableDir 'PFiles\TightVNC\tvnserver.exe'
@@ -529,9 +578,9 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
529
578
  Write-Detail "TightVNC configured: localhost-only, no VNC password (auth via HQ proxy)"
530
579
  }
531
580
 
532
- # Step 7: Setup websockify (WebSocket proxy for VNC)
533
- Write-Step 7 $totalSteps "Setting up websockify..."
534
- if (Test-CommandExists 'websockify') {
581
+ # Step 8: Setup websockify (WebSocket proxy for VNC)
582
+ Write-Step 8 $totalSteps "Setting up websockify..."
583
+ if (Get-WebsockifyCommand) {
535
584
  Write-Detail "websockify already installed"
536
585
  }
537
586
  else {
@@ -545,9 +594,10 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
545
594
  if (-not $pythonUsable) {
546
595
  Write-Host " Python not found. Installing via winget..."
547
596
  try {
548
- & winget install --id Python.Python.3.12 --accept-package-agreements --accept-source-agreements
597
+ $wingetResult = cmd /c "winget install --id Python.Python.3.12 --accept-package-agreements --accept-source-agreements 2>&1"
549
598
  $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
550
- Write-Detail "Python installed"
599
+ if ($LASTEXITCODE -eq 0) { Write-Detail "Python installed" }
600
+ else { Write-Warn "winget install may have failed (exit code $LASTEXITCODE): $wingetResult" }
551
601
  }
552
602
  catch {
553
603
  Write-Warn "Failed to install Python: $_"
@@ -557,28 +607,35 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
557
607
 
558
608
  Write-Host " Installing websockify via pip..."
559
609
  try {
610
+ # Use cmd /c to prevent pip's stderr (progress bars, warnings) from
611
+ # becoming RemoteException errors in PowerShell remoting sessions.
560
612
  if (Test-CommandExists 'pip') {
561
- & pip install websockify 2>$null
562
- Write-Detail "websockify installed"
613
+ $pipResult = cmd /c "pip install websockify 2>&1"
614
+ if ($LASTEXITCODE -eq 0) { Write-Detail "websockify installed" }
615
+ else { Write-Warn "pip install failed (exit code $LASTEXITCODE): $pipResult" }
563
616
  }
564
617
  elseif (Test-CommandExists 'pip3') {
565
- & pip3 install websockify 2>$null
566
- Write-Detail "websockify installed"
618
+ $pipResult = cmd /c "pip3 install websockify 2>&1"
619
+ if ($LASTEXITCODE -eq 0) { Write-Detail "websockify installed" }
620
+ else { Write-Warn "pip3 install failed (exit code $LASTEXITCODE): $pipResult" }
567
621
  }
568
622
  elseif (Test-CommandExists 'python') {
569
- & python -m pip install websockify 2>$null
570
- Write-Detail "websockify installed (via python -m pip)"
623
+ $pipResult = cmd /c "python -m pip install websockify 2>&1"
624
+ if ($LASTEXITCODE -eq 0) { Write-Detail "websockify installed (via python -m pip)" }
625
+ else { Write-Warn "python -m pip install failed (exit code $LASTEXITCODE): $pipResult" }
571
626
  }
572
627
  else {
573
628
  Write-Warn "pip not found. Install Python first, then: pip install websockify"
574
629
  }
630
+ # Refresh PATH so websockify.exe in Python Scripts dir is discoverable
631
+ $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
575
632
  }
576
633
  catch {
577
634
  Write-Warn "Failed to install websockify: $_"
578
635
  }
579
636
  }
580
637
 
581
- # Step 8 (optional): Cloudflare Tunnel
638
+ # Step 9 (optional): Cloudflare Tunnel
582
639
  if ($SetupTunnel) {
583
640
  $currentStep = $totalSteps - 1
584
641
  Write-Step $currentStep $totalSteps "Setting up Cloudflare Tunnel..."
@@ -842,6 +899,43 @@ switch ($Command) {
842
899
  Write-Error "Health check failed. Is the agent running?"
843
900
  }
844
901
  }
902
+ 'diagnose' {
903
+ Write-Host "Running diagnostics..."
904
+ Write-Host ""
905
+ try {
906
+ $result = Invoke-RestMethod -Uri "$AgentUrl/api/diagnose" -TimeoutSec 15
907
+ }
908
+ catch {
909
+ Write-Host "FAIL: Cannot reach minion agent at $AgentUrl" -ForegroundColor Red
910
+ Write-Host " Is the agent running? Try: minion-cli-win start"
911
+ exit 1
912
+ }
913
+
914
+ $ver = if ($result.version) { $result.version } else { '?' }
915
+ $plat = if ($result.platform) { $result.platform } else { '?' }
916
+ Write-Host "=== Minion Diagnostics (v$ver, $plat) ==="
917
+ Write-Host ""
918
+
919
+ $checkNames = @('agent', 'hq', 'tunnel', 'vnc', 'terminal', 'llm', 'env')
920
+ foreach ($check in $checkNames) {
921
+ $c = $result.checks.$check
922
+ $label = $check.ToUpper().PadRight(10)
923
+ if ($c.ok) {
924
+ Write-Host " [PASS] $label $($c.details)" -ForegroundColor Green
925
+ }
926
+ else {
927
+ Write-Host " [FAIL] $label $($c.details)" -ForegroundColor Red
928
+ }
929
+ }
930
+
931
+ Write-Host ""
932
+ if ($result.summary -eq 'ALL OK') {
933
+ Write-Host $result.summary -ForegroundColor Green
934
+ }
935
+ else {
936
+ Write-Host $result.summary -ForegroundColor Yellow
937
+ }
938
+ }
845
939
  'version' {
846
940
  Write-Host "@geekbeer/minion v$CliVersion (Windows)"
847
941
  }
@@ -856,6 +950,7 @@ switch ($Command) {
856
950
  Write-Host " minion-cli-win restart # Restart agent process"
857
951
  Write-Host " minion-cli-win status # Get current status"
858
952
  Write-Host " minion-cli-win health # Health check"
953
+ Write-Host " minion-cli-win diagnose # Run full service diagnostics"
859
954
  Write-Host " minion-cli-win version # Show version"
860
955
  Write-Host ""
861
956
  Write-Host "Setup options:"
@@ -1,7 +1,14 @@
1
1
  /**
2
2
  * Windows Chat endpoints
3
3
  *
4
- * Same as routes/chat.js but with Windows-compatible PATH construction.
4
+ * Same as linux/routes/chat.js but with Windows-compatible PATH and shell construction.
5
+ *
6
+ * Endpoints:
7
+ * POST /api/chat - Send message, get SSE stream
8
+ * GET /api/chat/session - Get active session (messages + session_id)
9
+ * POST /api/chat/clear - Clear session and start fresh
10
+ * POST /api/chat/abort - Kill the active LLM CLI process
11
+ * POST /api/chat/reset - Summarize conversation and start fresh session
5
12
  */
6
13
 
7
14
  const { spawn } = require('child_process')
@@ -99,6 +106,42 @@ async function chatRoutes(fastify) {
99
106
  }, 2000)
100
107
  return { success: true }
101
108
  })
109
+
110
+ // POST /api/chat/reset - Summarize conversation and start fresh session
111
+ fastify.post('/api/chat/reset', async (request, reply) => {
112
+ if (!verifyToken(request)) {
113
+ reply.code(401)
114
+ return { success: false, error: 'Unauthorized' }
115
+ }
116
+
117
+ const session = await chatStore.load()
118
+ if (!session || session.messages.length === 0) {
119
+ await chatStore.clear()
120
+ return { success: true, summary: null }
121
+ }
122
+
123
+ const recentMessages = session.messages.slice(-20)
124
+ const conversationText = recentMessages
125
+ .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
126
+ .join('\n\n')
127
+
128
+ const summarizePrompt = `以下の会話を要約してください。重要なコンテキスト、決定事項、進行中のタスクを含めてください。200文字以内で。\n\n${conversationText}`
129
+
130
+ let summary = null
131
+ try {
132
+ summary = await runQuickLlmCall(summarizePrompt)
133
+ } catch (err) {
134
+ console.error('[Chat] summarization failed:', err.message)
135
+ const fallback = recentMessages.slice(-4)
136
+ .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 200)}`)
137
+ .join('\n')
138
+ summary = fallback
139
+ }
140
+
141
+ await chatStore.clear()
142
+ console.log(`[Chat] session reset with summary (${summary?.length || 0} chars)`)
143
+ return { success: true, summary }
144
+ })
102
145
  }
103
146
 
104
147
  function buildContextPrefix(message, context) {
@@ -154,6 +197,12 @@ function getLlmBinary() {
154
197
  return cmd.split(/\s+/)[0]
155
198
  }
156
199
 
200
+ /**
201
+ * Stream LLM CLI output as SSE events.
202
+ * Uses --resume to continue existing sessions.
203
+ * Tracks block types to correctly forward tool_use vs text events
204
+ * and counts turns for session management.
205
+ */
157
206
  function streamLlmResponse(res, prompt, sessionId) {
158
207
  return new Promise((resolve, reject) => {
159
208
  const binaryName = getLlmBinary()
@@ -177,6 +226,7 @@ function streamLlmResponse(res, prompt, sessionId) {
177
226
  cwd: config.HOME_DIR,
178
227
  stdio: ['pipe', 'pipe', 'pipe'],
179
228
  timeout: 600000,
229
+ shell: true, // Required on Windows to resolve .cmd shims (e.g. claude.cmd)
180
230
  env: {
181
231
  ...process.env,
182
232
  HOME: config.HOME_DIR,
@@ -195,6 +245,12 @@ function streamLlmResponse(res, prompt, sessionId) {
195
245
  let lineBuffer = ''
196
246
  let resolvedSessionId = sessionId || null
197
247
 
248
+ // Block-type state tracking for correct event forwarding
249
+ let currentBlockType = null // 'text' | 'tool_use' | null
250
+ let currentToolName = null
251
+ let toolInputBuffer = ''
252
+ let turnCount = 0
253
+
198
254
  child.stdout.on('data', (data) => {
199
255
  lineBuffer += data.toString()
200
256
  const parts = lineBuffer.split('\n')
@@ -204,35 +260,85 @@ function streamLlmResponse(res, prompt, sessionId) {
204
260
  if (!line.trim()) continue
205
261
  try {
206
262
  const parsed = JSON.parse(line)
263
+
264
+ // system init event — capture session_id
207
265
  if (parsed.type === 'system' && parsed.session_id) {
208
266
  resolvedSessionId = parsed.session_id
209
267
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
210
268
  }
211
- if (parsed.type === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
212
- const event = JSON.stringify({ type: 'tool_start', tool: parsed.content_block.name || 'unknown' })
213
- res.write(`data: ${event}\n\n`)
269
+
270
+ // content_block_start track block type
271
+ if (parsed.type === 'content_block_start') {
272
+ const blockType = parsed.content_block?.type
273
+ if (blockType === 'tool_use') {
274
+ currentBlockType = 'tool_use'
275
+ currentToolName = parsed.content_block.name || 'unknown'
276
+ toolInputBuffer = ''
277
+ const event = JSON.stringify({
278
+ type: 'tool_start',
279
+ tool: currentToolName,
280
+ })
281
+ res.write(`data: ${event}\n\n`)
282
+ } else if (blockType === 'text') {
283
+ currentBlockType = 'text'
284
+ }
285
+ }
286
+
287
+ // content_block_delta — handle both text and tool input
288
+ if (parsed.type === 'content_block_delta') {
289
+ const deltaType = parsed.delta?.type
290
+ if (deltaType === 'input_json_delta' && currentBlockType === 'tool_use') {
291
+ const partial = parsed.delta.partial_json || ''
292
+ if (partial) {
293
+ toolInputBuffer += partial
294
+ const event = JSON.stringify({ type: 'tool_input_delta', partial_json: partial })
295
+ res.write(`data: ${event}\n\n`)
296
+ }
297
+ } else {
298
+ const delta = parsed.delta?.text || ''
299
+ if (delta) {
300
+ fullResponse += delta
301
+ const event = JSON.stringify({ type: 'delta', content: delta })
302
+ res.write(`data: ${event}\n\n`)
303
+ }
304
+ }
214
305
  }
306
+
307
+ // content_block_stop — only emit tool_end for tool_use blocks (bug fix)
215
308
  if (parsed.type === 'content_block_stop') {
216
- res.write(`data: ${JSON.stringify({ type: 'tool_end' })}\n\n`)
309
+ if (currentBlockType === 'tool_use') {
310
+ let parsedInput = null
311
+ try {
312
+ if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer)
313
+ } catch { /* partial or invalid JSON */ }
314
+ const event = JSON.stringify({
315
+ type: 'tool_end',
316
+ tool: currentToolName,
317
+ input: parsedInput,
318
+ })
319
+ res.write(`data: ${event}\n\n`)
320
+ }
321
+ currentBlockType = null
322
+ currentToolName = null
323
+ toolInputBuffer = ''
217
324
  }
325
+
326
+ // assistant message — count turns and forward text blocks
218
327
  if (parsed.type === 'assistant' && parsed.message) {
328
+ turnCount++
219
329
  for (const block of (parsed.message.content || [])) {
220
330
  if (block.type === 'text') {
221
331
  fullResponse += block.text
222
332
  res.write(`data: ${JSON.stringify({ type: 'text', content: block.text })}\n\n`)
223
333
  }
224
334
  }
225
- } else if (parsed.type === 'content_block_delta') {
226
- const delta = parsed.delta?.text || ''
227
- if (delta) {
228
- fullResponse += delta
229
- res.write(`data: ${JSON.stringify({ type: 'delta', content: delta })}\n\n`)
230
- }
231
335
  } else if (parsed.type === 'result') {
232
336
  const resultText = parsed.result || ''
233
337
  if (resultText) {
234
338
  res.write(`data: ${JSON.stringify({ type: 'result', content: resultText })}\n\n`)
235
- fullResponse = resultText
339
+ if (!fullResponse) {
340
+ fullResponse = resultText
341
+ }
236
342
  }
237
343
  }
238
344
  } catch {
@@ -254,14 +360,18 @@ function streamLlmResponse(res, prompt, sessionId) {
254
360
  await chatStore.addMessage(resolvedSessionId, { role: 'user', content: prompt })
255
361
  }
256
362
  if (fullResponse) {
257
- await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse })
363
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount)
258
364
  }
259
365
  }
260
366
  if (code !== 0 && !fullResponse) {
261
367
  const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
262
368
  res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
263
369
  }
264
- res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId })}\n\n`)
370
+
371
+ const session = await chatStore.load()
372
+ const totalTurnCount = session?.turn_count || turnCount
373
+
374
+ res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
265
375
  resolve()
266
376
  })
267
377
 
@@ -277,4 +387,58 @@ function streamLlmResponse(res, prompt, sessionId) {
277
387
  })
278
388
  }
279
389
 
390
+ /**
391
+ * Run a quick non-streaming LLM call (for summarization etc.)
392
+ */
393
+ function runQuickLlmCall(prompt) {
394
+ return new Promise((resolve, reject) => {
395
+ const binaryName = getLlmBinary()
396
+ if (!binaryName) {
397
+ reject(new Error('LLM_COMMAND is not configured'))
398
+ return
399
+ }
400
+ const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
401
+ const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
402
+ const extendedPath = buildExtendedPath(config.HOME_DIR)
403
+
404
+ const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json', prompt]
405
+
406
+ const child = spawn(binary, args, {
407
+ cwd: config.HOME_DIR,
408
+ stdio: ['pipe', 'pipe', 'pipe'],
409
+ timeout: 30000,
410
+ shell: true,
411
+ env: {
412
+ ...process.env,
413
+ HOME: config.HOME_DIR,
414
+ USERPROFILE: config.HOME_DIR,
415
+ PATH: extendedPath,
416
+ },
417
+ })
418
+
419
+ child.stdin.end()
420
+
421
+ let stdout = ''
422
+ let stderr = ''
423
+
424
+ child.stdout.on('data', (data) => { stdout += data.toString() })
425
+ child.stderr.on('data', (data) => { stderr += data.toString() })
426
+
427
+ child.on('close', (code) => {
428
+ if (code !== 0) {
429
+ reject(new Error(`LLM call failed (exit ${code}): ${stderr.substring(0, 200)}`))
430
+ return
431
+ }
432
+ try {
433
+ const parsed = JSON.parse(stdout)
434
+ resolve(parsed.result || stdout.trim())
435
+ } catch {
436
+ resolve(stdout.trim())
437
+ }
438
+ })
439
+
440
+ child.on('error', (err) => reject(err))
441
+ })
442
+ }
443
+
280
444
  module.exports = { chatRoutes }
@@ -10,7 +10,7 @@ const path = require('path')
10
10
  const zlib = require('zlib')
11
11
  const { verifyToken } = require('../../core/lib/auth')
12
12
  const { clearLlmCache } = require('../../core/lib/llm-checker')
13
- const { config } = require('../../core/config')
13
+ const { config, updateConfig } = require('../../core/config')
14
14
  const { resolveEnvFilePath } = require('../../core/lib/platform')
15
15
 
16
16
  const ALLOWED_ENV_KEYS = ['LLM_COMMAND']
@@ -212,8 +212,12 @@ function configRoutes(fastify, _opts, done) {
212
212
  try {
213
213
  writeEnvKey(envPath, key, value)
214
214
  console.log(`[Config] Updated ${key} in ${envPath}`)
215
+
216
+ // Sync in-memory config so the change takes effect without restart
217
+ updateConfig(key, value)
218
+
215
219
  clearLlmCache()
216
- return { success: true, restart_required: true }
220
+ return { success: true, restart_required: false }
217
221
  } catch (err) {
218
222
  console.error(`[Config] Failed to update ${key} in ${envPath}:`, err.message)
219
223
  const detail = err.code === 'EACCES' ? ' (permission denied)' : ''
@@ -8,6 +8,7 @@
8
8
  const { Cron } = require('croner')
9
9
  const crypto = require('crypto')
10
10
  const path = require('path')
11
+ const { stripAnsi } = require('../core/lib/strip-ansi')
11
12
  const fs = require('fs').promises
12
13
  const fsSync = require('fs')
13
14
 
@@ -150,11 +151,12 @@ async function executeRoutineSession(routine, executionId, skillNames) {
150
151
  }, timeout)
151
152
 
152
153
  ptyProcess.onData((data) => {
153
- session.buffer += data
154
+ const cleaned = stripAnsi(data)
155
+ session.buffer += cleaned
154
156
  if (session.buffer.length > 1024 * 1024) {
155
157
  session.buffer = session.buffer.slice(-512 * 1024)
156
158
  }
157
- try { logStream.write(data) } catch { /* ignore */ }
159
+ try { logStream.write(cleaned) } catch { /* ignore */ }
158
160
  })
159
161
 
160
162
  ptyProcess.onExit(({ exitCode }) => {