@geekbeer/minion 2.16.5 → 2.25.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.
package/routes/auth.js CHANGED
@@ -1,272 +1,16 @@
1
1
  /**
2
- * Authentication management endpoints
2
+ * LLM authentication status endpoint
3
3
  *
4
- * Provides guided LLM authentication flow for non-engineer users.
5
- * Uses tmux to run `claude auth login` in a named session that can be:
6
- * - Observed from the terminal: `tmux attach -t claude-auth`
7
- * - Controlled via `tmux send-keys` for reliable key input
8
- * - Read via `tmux capture-pane` for output extraction
4
+ * Reports which LLM services are authenticated on this minion.
5
+ * Authentication itself is the user's responsibility they should
6
+ * use the terminal (VNC or ttyd) to run their LLM CLI's login command.
9
7
  *
10
- * Flow:
11
- * 1. POST /api/auth/start - Start tmux session, navigate menus, extract OAuth URL
12
- * 2. User opens URL in their browser, authenticates, receives a code
13
- * 3. POST /api/auth/code - Submit the code to the tmux session
14
- * 4. GET /api/auth/status - Poll for authentication completion
8
+ * Endpoints:
9
+ * GET /api/auth/status - Get LLM authentication status
15
10
  */
16
11
 
17
- const { execSync, exec } = require('child_process')
18
- const fs = require('fs')
19
- const path = require('path')
20
12
  const { verifyToken } = require('../lib/auth')
21
- const { getLlmServices, clearLlmCache } = require('../lib/llm-checker')
22
- const { config } = require('../config')
23
-
24
- const TMUX_SESSION = 'claude-auth'
25
-
26
- let authInProgress = false
27
- let authLockTimer = null
28
-
29
- /**
30
- * Release the auth lock and clean up
31
- */
32
- function releaseAuthLock() {
33
- authInProgress = false
34
- if (authLockTimer) {
35
- clearTimeout(authLockTimer)
36
- authLockTimer = null
37
- }
38
- // Kill tmux session if still running
39
- try { execSync(`tmux kill-session -t ${TMUX_SESSION} 2>/dev/null`) } catch {}
40
- }
41
-
42
- /** Strip ANSI escape sequences for readable logging */
43
- function stripAnsi(str) {
44
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
45
- }
46
-
47
- /**
48
- * Capture the current tmux pane content
49
- */
50
- function captureTmuxPane() {
51
- try {
52
- return execSync(
53
- `tmux capture-pane -t ${TMUX_SESSION} -p -J -S -50`,
54
- { encoding: 'utf-8', timeout: 5000 }
55
- )
56
- } catch {
57
- return ''
58
- }
59
- }
60
-
61
- /**
62
- * Check if the tmux session exists
63
- */
64
- function tmuxSessionExists() {
65
- try {
66
- execSync(`tmux has-session -t ${TMUX_SESSION} 2>/dev/null`)
67
- return true
68
- } catch {
69
- return false
70
- }
71
- }
72
-
73
- /**
74
- * Extract OAuth URL from tmux pane content
75
- */
76
- function extractUrlFromPane(content) {
77
- const urlMatch = content.match(/(https:\/\/[^\s]+)/)
78
- return urlMatch ? urlMatch[1] : null
79
- }
80
-
81
- /**
82
- * Start Claude Code login in a tmux session.
83
- *
84
- * Interactive flow:
85
- * 1. Theme selection → send Enter (accept default)
86
- * 2. Login method → send Enter (accept default = Subscription)
87
- * 3. OAuth URL output → capture and return
88
- *
89
- * Uses content-based polling instead of fixed timers so that first-run
90
- * CLI initialization delays don't cause Enter keys to be lost.
91
- *
92
- * Returns a promise that resolves with the OAuth URL.
93
- * The tmux session stays alive for code submission.
94
- */
95
- function startClaudeAuth() {
96
- return new Promise((resolve, reject) => {
97
- // Kill any leftover session
98
- try { execSync(`tmux kill-session -t ${TMUX_SESSION} 2>/dev/null`) } catch {}
99
-
100
- console.log('[Auth] Creating tmux session for claude auth login')
101
-
102
- // Extend PATH to include common CLI installation locations
103
- // (systemd provides a minimal PATH that excludes ~/.local/bin where claude is installed)
104
- const additionalPaths = [
105
- path.join(config.HOME_DIR, '.local', 'bin'),
106
- path.join(config.HOME_DIR, 'bin'),
107
- path.join(config.HOME_DIR, '.npm-global', 'bin'),
108
- path.join(config.HOME_DIR, '.claude', 'bin'),
109
- '/usr/local/bin',
110
- ]
111
- const extendedPath = [...additionalPaths, process.env.PATH || '/usr/bin:/bin'].join(':')
112
-
113
- try {
114
- // Start claude auth login in a new tmux session
115
- // Use inline env vars in the shell command for compatibility with all tmux versions
116
- // (tmux -e flag requires tmux 3.2+)
117
- // CLAUDECODE='' prevents the nesting check
118
- execSync(
119
- `tmux new-session -d -s ${TMUX_SESSION} -x 500 -y 40 'export HOME="${config.HOME_DIR}" PATH="${extendedPath}"; CLAUDECODE="" claude auth login'`,
120
- { timeout: 5000 }
121
- )
122
- // Keep pane alive after command exits so we can capture error output
123
- execSync(`tmux set-option -t ${TMUX_SESSION} remain-on-exit on`)
124
- } catch (err) {
125
- reject(new Error(`Failed to create tmux session: ${err.message}`))
126
- return
127
- }
128
-
129
- console.log('[Auth] tmux session created, polling for menus...')
130
-
131
- // State machine for menu navigation
132
- // 'wait_theme' → waiting for theme selection menu to appear
133
- // 'wait_login' → Enter sent for theme, waiting for login method menu
134
- // 'wait_url' → Enter sent for login method, waiting for OAuth URL
135
- let stage = 'wait_theme'
136
- let resolved = false
137
- let lastContent = ''
138
-
139
- const pollInterval = setInterval(() => {
140
- if (resolved) return
141
-
142
- // Check if the process inside the pane has exited (remain-on-exit keeps pane alive)
143
- let paneDead = false
144
- try {
145
- const deadFlag = execSync(
146
- `tmux list-panes -t ${TMUX_SESSION} -F '#{pane_dead}'`,
147
- { encoding: 'utf-8', timeout: 3000 }
148
- ).trim()
149
- paneDead = deadFlag === '1'
150
- } catch {
151
- // Session itself is gone
152
- resolved = true
153
- clearInterval(pollInterval)
154
- reject(new Error('Auth session ended unexpectedly'))
155
- return
156
- }
157
-
158
- const content = captureTmuxPane()
159
- const clean = stripAnsi(content)
160
-
161
- // If process exited, capture output for diagnostics and report error
162
- if (paneDead) {
163
- resolved = true
164
- clearInterval(pollInterval)
165
- console.error(`[Auth] Process exited. Pane output:\n${clean.slice(0, 1000)}`)
166
- try { execSync(`tmux kill-session -t ${TMUX_SESSION} 2>/dev/null`) } catch {}
167
- reject(new Error(`Auth process exited. Output: ${clean.slice(0, 300) || '(none)'}`))
168
- return
169
- }
170
-
171
- // Check for URL at any stage
172
- const url = extractUrlFromPane(content)
173
- if (url) {
174
- resolved = true
175
- clearInterval(pollInterval)
176
- console.log(`[Auth] URL found: ${url}`)
177
- resolve(url)
178
- return
179
- }
180
-
181
- if (stage === 'wait_theme') {
182
- // Detect theme selection menu: look for "text style" or menu indicators
183
- if (clean.match(/text\s*style|theme|dark|light/i) && clean.length > 20) {
184
- console.log('[Auth] Theme menu detected, sending Enter #1')
185
- try { execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`) } catch {}
186
- stage = 'wait_login'
187
- lastContent = clean
188
- }
189
- } else if (stage === 'wait_login') {
190
- // Detect login method menu: look for "login" or "subscription" or content change
191
- if (clean !== lastContent && (
192
- clean.match(/login\s*method|subscription|account|how.*login/i) ||
193
- clean.match(/anthropic|console|api\s*key/i) ||
194
- // Content changed significantly after Enter — likely new menu
195
- clean.length > lastContent.length + 10
196
- )) {
197
- console.log('[Auth] Login method menu detected, sending Enter #2')
198
- try { execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`) } catch {}
199
- stage = 'wait_url'
200
- }
201
- }
202
- // stage === 'wait_url': just keep polling for URL (handled above)
203
-
204
- }, 1000)
205
-
206
- // Timeout after 60 seconds (longer for first-run initialization)
207
- setTimeout(() => {
208
- if (!resolved) {
209
- resolved = true
210
- clearInterval(pollInterval)
211
- const content = captureTmuxPane()
212
- const clean = stripAnsi(content).trim()
213
- console.error(`[Auth] Timed out at stage=${stage}. Pane content: ${clean.slice(0, 500)}`)
214
- reject(new Error(
215
- `Timed out waiting for auth URL (stage: ${stage}). Output: ${clean.slice(0, 300) || '(none)'}`
216
- ))
217
- }
218
- }, 60000)
219
- })
220
- }
221
-
222
- /**
223
- * Submit an auth code to the running tmux session.
224
- * Types the code and presses Enter.
225
- */
226
- function submitAuthCode(code) {
227
- if (!tmuxSessionExists()) {
228
- return { success: false, error: 'No auth session running' }
229
- }
230
-
231
- // Check if the process inside the pane is still alive
232
- // (remain-on-exit keeps the session but the process may have exited)
233
- try {
234
- const deadFlag = execSync(
235
- `tmux list-panes -t ${TMUX_SESSION} -F '#{pane_dead}'`,
236
- { encoding: 'utf-8', timeout: 3000 }
237
- ).trim()
238
- if (deadFlag === '1') {
239
- const content = captureTmuxPane()
240
- const clean = stripAnsi(content).trim()
241
- console.error(`[Auth] Cannot submit code: auth process already exited. Pane output:\n${clean.slice(0, 500)}`)
242
- try { execSync(`tmux kill-session -t ${TMUX_SESSION} 2>/dev/null`) } catch {}
243
- return { success: false, error: `Auth process already exited. Output: ${clean.slice(0, 200) || '(none)'}` }
244
- }
245
- } catch {
246
- return { success: false, error: 'No auth session running' }
247
- }
248
-
249
- try {
250
- console.log('[Auth] Submitting auth code to tmux session')
251
- // Use send-keys with -l (literal) to avoid interpreting special chars
252
- execSync(`tmux send-keys -t ${TMUX_SESSION} -l '${code.replace(/'/g, "'\\''")}'`)
253
- execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`)
254
-
255
- // Log pane content after submission for diagnostics
256
- setTimeout(() => {
257
- try {
258
- const content = captureTmuxPane()
259
- const clean = stripAnsi(content).trim()
260
- console.log(`[Auth] Pane content after code submission:\n${clean.slice(0, 500)}`)
261
- } catch {}
262
- }, 3000)
263
-
264
- return { success: true }
265
- } catch (err) {
266
- console.error(`[Auth] Failed to send code: ${err.message}`)
267
- return { success: false, error: 'Failed to submit code to auth session' }
268
- }
269
- }
13
+ const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
270
14
 
271
15
  /**
272
16
  * Register auth routes as Fastify plugin
@@ -274,163 +18,19 @@ function submitAuthCode(code) {
274
18
  */
275
19
  async function authRoutes(fastify) {
276
20
 
277
- // Start LLM authentication flow
278
- fastify.post('/api/auth/start', async (request, reply) => {
279
- if (!verifyToken(request)) {
280
- reply.code(401)
281
- return { success: false, error: 'Unauthorized' }
282
- }
283
-
284
- const { service } = request.body || {}
285
- const targetService = service || 'claude'
286
-
287
- if (targetService !== 'claude') {
288
- reply.code(400)
289
- return { success: false, error: `Unsupported service: ${targetService}` }
290
- }
291
-
292
- // Check if already authenticated
293
- const services = getLlmServices()
294
- const claude = services.find(s => s.name === 'claude')
295
- if (claude && claude.authenticated) {
296
- return { success: true, already_authenticated: true }
297
- }
298
-
299
- // Clean up any previous auth session and start fresh
300
- if (authInProgress) {
301
- console.log('[Auth] Cleaning up previous auth session')
302
- releaseAuthLock()
303
- }
304
-
305
- authInProgress = true
306
- // Safety timeout — release lock after 5 minutes
307
- authLockTimer = setTimeout(releaseAuthLock, 300000)
308
-
309
- console.log('[Auth] Starting Claude Code authentication flow')
310
-
311
- try {
312
- const authUrl = await startClaudeAuth()
313
- console.log('[Auth] Auth URL obtained successfully')
314
- return { success: true, auth_url: authUrl }
315
- } catch (err) {
316
- console.error(`[Auth] Failed: ${err.message}`)
317
- releaseAuthLock()
318
- return {
319
- success: false,
320
- error: err.message,
321
- fallback: 'Open Terminal and run: claude auth login',
322
- }
323
- }
324
- })
325
-
326
- // Submit auth code to running session
327
- fastify.post('/api/auth/code', async (request, reply) => {
328
- if (!verifyToken(request)) {
329
- reply.code(401)
330
- return { success: false, error: 'Unauthorized' }
331
- }
332
-
333
- const { code } = request.body || {}
334
- if (!code || typeof code !== 'string') {
335
- reply.code(400)
336
- return { success: false, error: 'Missing auth code' }
337
- }
338
-
339
- const result = submitAuthCode(code.trim())
340
- if (!result.success) {
341
- reply.code(409)
342
- }
343
- return result
344
- })
345
-
346
- // Logout from LLM service
347
- fastify.post('/api/auth/logout', async (request, reply) => {
348
- if (!verifyToken(request)) {
349
- reply.code(401)
350
- return { success: false, error: 'Unauthorized' }
351
- }
352
-
353
- const { service } = request.body || {}
354
- const targetService = service || 'claude'
355
-
356
- if (targetService !== 'claude') {
357
- reply.code(400)
358
- return { success: false, error: `Unsupported service: ${targetService}` }
359
- }
360
-
361
- // Delete credential files directly (more reliable than CLI which may be interactive)
362
- const credPaths = [
363
- path.join(config.HOME_DIR, '.claude', '.credentials.json'),
364
- path.join(config.HOME_DIR, '.claude', 'credentials.json'),
365
- ]
366
-
367
- let deleted = 0
368
- for (const p of credPaths) {
369
- try {
370
- if (fs.existsSync(p)) {
371
- fs.unlinkSync(p)
372
- console.log(`[Auth] Deleted: ${p}`)
373
- deleted++
374
- }
375
- } catch (err) {
376
- console.error(`[Auth] Failed to delete ${p}: ${err.message}`)
377
- }
378
- }
379
-
380
- // Clear cached LLM status so next check reflects the change
381
- clearLlmCache()
382
-
383
- console.log(`[Auth] Logout completed (${deleted} credential files removed)`)
384
- return { success: true }
385
- })
386
-
387
- // Get current LLM authentication status (for polling)
21
+ // Get current LLM authentication status
388
22
  fastify.get('/api/auth/status', async (request, reply) => {
389
23
  if (!verifyToken(request)) {
390
24
  reply.code(401)
391
25
  return { success: false, error: 'Unauthorized' }
392
26
  }
393
27
 
394
- // During active auth flow, bypass cache so credential changes are detected immediately
395
- if (authInProgress) {
396
- clearLlmCache()
397
-
398
- // List all files in ~/.claude/ for diagnostics
399
- const claudeDir = path.join(config.HOME_DIR, '.claude')
400
- try {
401
- if (fs.existsSync(claudeDir)) {
402
- const listFiles = (dir, prefix = '') => {
403
- const entries = fs.readdirSync(dir, { withFileTypes: true })
404
- for (const e of entries) {
405
- const rel = prefix ? `${prefix}/${e.name}` : e.name
406
- if (e.isDirectory()) listFiles(path.join(dir, e.name), rel)
407
- else console.log(`[Auth] ~/.claude/${rel}`)
408
- }
409
- }
410
- listFiles(claudeDir)
411
- } else {
412
- console.log(`[Auth] ~/.claude/ directory does not exist`)
413
- }
414
- } catch (err) {
415
- console.log(`[Auth] Failed to list ~/.claude/: ${err.message}`)
416
- }
417
- }
418
-
419
28
  const services = getLlmServices()
420
29
 
421
- // Auto-release auth lock when Claude is now authenticated
422
- if (authInProgress) {
423
- const claude = services.find(s => s.name === 'claude')
424
- if (claude && claude.authenticated) {
425
- console.log('[Auth] Authentication detected, releasing auth lock')
426
- releaseAuthLock()
427
- }
428
- }
429
-
430
30
  return {
431
31
  success: true,
432
32
  services,
433
- auth_in_progress: authInProgress,
33
+ llm_command_configured: isLlmCommandConfigured(),
434
34
  }
435
35
  })
436
36
  }
package/routes/chat.js CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Chat endpoints
3
3
  *
4
- * Provides streaming chat with Claude Code CLI using `--resume` sessions.
4
+ * Provides streaming chat with the configured LLM CLI using `--resume` sessions.
5
5
  * Messages are sent via POST with SSE response for real-time streaming.
6
6
  * Session state is persisted to local JSON via chat-store.js.
7
7
  *
8
+ * Requires LLM_COMMAND to be configured in minion.env.
9
+ *
8
10
  * Endpoints:
9
11
  * POST /api/chat - Send message, get SSE stream
10
12
  * GET /api/chat/session - Get active session (messages + session_id)
11
13
  * POST /api/chat/clear - Clear session and start fresh
14
+ * POST /api/chat/abort - Kill the active LLM CLI process
12
15
  */
13
16
 
14
17
  const { spawn } = require('child_process')
@@ -18,6 +21,9 @@ const { verifyToken } = require('../lib/auth')
18
21
  const { config } = require('../config')
19
22
  const chatStore = require('../chat-store')
20
23
 
24
+ /** @type {import('child_process').ChildProcess | null} */
25
+ let activeChatChild = null
26
+
21
27
  /**
22
28
  * Register chat routes as Fastify plugin
23
29
  * @param {import('fastify').FastifyInstance} fastify
@@ -60,7 +66,7 @@ async function chatRoutes(fastify) {
60
66
  reply.raw.flushHeaders()
61
67
 
62
68
  try {
63
- await streamClaudeResponse(reply.raw, prompt, currentSessionId)
69
+ await streamLlmResponse(reply.raw, prompt, currentSessionId)
64
70
  } catch (err) {
65
71
  console.error('[Chat] stream error:', err.message)
66
72
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
@@ -103,6 +109,33 @@ async function chatRoutes(fastify) {
103
109
  await chatStore.clear()
104
110
  return { success: true }
105
111
  })
112
+
113
+ // POST /api/chat/abort - Kill the active Claude CLI process
114
+ fastify.post('/api/chat/abort', async (request, reply) => {
115
+ if (!verifyToken(request)) {
116
+ reply.code(401)
117
+ return { success: false, error: 'Unauthorized' }
118
+ }
119
+
120
+ if (!activeChatChild) {
121
+ return { success: false, error: 'No active chat process' }
122
+ }
123
+
124
+ console.log(`[Chat] Aborting active chat process PID: ${activeChatChild.pid}`)
125
+ activeChatChild.kill('SIGTERM')
126
+
127
+ // Give it 2s to terminate gracefully, then force kill
128
+ const pid = activeChatChild.pid
129
+ setTimeout(() => {
130
+ try {
131
+ if (activeChatChild && activeChatChild.pid === pid) {
132
+ activeChatChild.kill('SIGKILL')
133
+ }
134
+ } catch { /* already dead */ }
135
+ }, 2000)
136
+
137
+ return { success: true }
138
+ })
106
139
  }
107
140
 
108
141
  /**
@@ -165,13 +198,30 @@ function buildContextPrefix(message, context) {
165
198
  }
166
199
 
167
200
  /**
168
- * Stream Claude Code CLI output as SSE events.
201
+ * Extract binary name from LLM_COMMAND template.
202
+ * e.g., "claude -p '{prompt}'" → "claude"
203
+ */
204
+ function getLlmBinary() {
205
+ const cmd = config.LLM_COMMAND
206
+ if (!cmd) return null
207
+ return cmd.split(/\s+/)[0]
208
+ }
209
+
210
+ /**
211
+ * Stream LLM CLI output as SSE events.
169
212
  * Uses --resume to continue existing sessions.
213
+ * Note: Chat-specific flags (--output-format stream-json, --resume, etc.)
214
+ * are Claude Code CLI features. Other LLM CLIs may not support them.
170
215
  */
171
- function streamClaudeResponse(res, prompt, sessionId) {
216
+ function streamLlmResponse(res, prompt, sessionId) {
172
217
  return new Promise((resolve, reject) => {
173
- const claudePath = path.join(config.HOME_DIR, '.local', 'bin', 'claude')
174
- const claudeBin = fs.existsSync(claudePath) ? claudePath : 'claude'
218
+ const binaryName = getLlmBinary()
219
+ if (!binaryName) {
220
+ reject(new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env'))
221
+ return
222
+ }
223
+ const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
224
+ const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
175
225
 
176
226
  const extendedPath = [
177
227
  `${config.HOME_DIR}/bin`,
@@ -183,12 +233,12 @@ function streamClaudeResponse(res, prompt, sessionId) {
183
233
  '/bin',
184
234
  ].join(':')
185
235
 
186
- // Build CLI args
236
+ // Build CLI args (no --max-turns: allow unlimited turns for task completion)
187
237
  const args = [
188
238
  '-p',
189
239
  '--verbose',
240
+ '--model', 'sonnet',
190
241
  '--output-format', 'stream-json',
191
- '--max-turns', '10',
192
242
  ]
193
243
 
194
244
  // Resume existing session
@@ -198,12 +248,12 @@ function streamClaudeResponse(res, prompt, sessionId) {
198
248
 
199
249
  args.push(prompt)
200
250
 
201
- console.log(`[Chat] spawning: ${claudeBin} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
251
+ console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
202
252
 
203
- const child = spawn(claudeBin, args, {
253
+ const child = spawn(binary, args, {
204
254
  cwd: config.HOME_DIR,
205
255
  stdio: ['pipe', 'pipe', 'pipe'],
206
- timeout: 300000, // 5 min
256
+ timeout: 600000, // 10 min
207
257
  env: {
208
258
  ...process.env,
209
259
  HOME: config.HOME_DIR,
@@ -212,6 +262,9 @@ function streamClaudeResponse(res, prompt, sessionId) {
212
262
  },
213
263
  })
214
264
 
265
+ // Track active child process for abort
266
+ activeChatChild = child
267
+
215
268
  // Close stdin immediately so CLI doesn't wait for input
216
269
  child.stdin.end()
217
270
 
@@ -239,6 +292,16 @@ function streamClaudeResponse(res, prompt, sessionId) {
239
292
  console.log(`[Chat] session_id: ${resolvedSessionId}`)
240
293
  }
241
294
 
295
+ // tool_use events — forward to frontend for progress display
296
+ if (parsed.type === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
297
+ const event = JSON.stringify({ type: 'tool_start', tool: parsed.content_block.name || 'unknown' })
298
+ res.write(`data: ${event}\n\n`)
299
+ }
300
+ if (parsed.type === 'content_block_stop') {
301
+ const event = JSON.stringify({ type: 'tool_end' })
302
+ res.write(`data: ${event}\n\n`)
303
+ }
304
+
242
305
  // assistant message content blocks
243
306
  if (parsed.type === 'assistant' && parsed.message) {
244
307
  for (const block of (parsed.message.content || [])) {
@@ -263,11 +326,6 @@ function streamClaudeResponse(res, prompt, sessionId) {
263
326
  res.write(`data: ${event}\n\n`)
264
327
  fullResponse = resultText
265
328
  }
266
- // If max turns was reached, notify frontend
267
- if (parsed.subtype === 'error_max_turns' && !resultText) {
268
- const event = JSON.stringify({ type: 'error', error: 'Max turns reached — response may be incomplete' })
269
- res.write(`data: ${event}\n\n`)
270
- }
271
329
  }
272
330
  } catch {
273
331
  // Non-JSON line — ignore
@@ -283,6 +341,8 @@ function streamClaudeResponse(res, prompt, sessionId) {
283
341
  })
284
342
 
285
343
  child.on('close', async (code) => {
344
+ activeChatChild = null
345
+
286
346
  // Store messages in chat-store
287
347
  if (resolvedSessionId) {
288
348
  // If this was a new session, also store the user message now
@@ -312,6 +372,7 @@ function streamClaudeResponse(res, prompt, sessionId) {
312
372
  })
313
373
 
314
374
  child.on('error', (err) => {
375
+ activeChatChild = null
315
376
  console.error(`[Chat] spawn error: ${err.message}`)
316
377
  const errorEvent = JSON.stringify({ type: 'error', error: `Failed to start Claude CLI: ${err.message}` })
317
378
  res.write(`data: ${errorEvent}\n\n`)