@geekbeer/minion 2.10.3 → 2.16.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 ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Authentication management endpoints
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
9
+ *
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
15
+ */
16
+
17
+ const { execSync, exec } = require('child_process')
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+ 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
+ try {
103
+ // Start claude auth login in a new tmux session
104
+ // CLAUDECODE='' prevents the nesting check
105
+ execSync(
106
+ `tmux new-session -d -s ${TMUX_SESSION} -x 500 -y 40 'CLAUDECODE="" claude auth login'`,
107
+ { timeout: 5000 }
108
+ )
109
+ } catch (err) {
110
+ reject(new Error(`Failed to create tmux session: ${err.message}`))
111
+ return
112
+ }
113
+
114
+ console.log('[Auth] tmux session created, polling for menus...')
115
+
116
+ // State machine for menu navigation
117
+ // 'wait_theme' → waiting for theme selection menu to appear
118
+ // 'wait_login' → Enter sent for theme, waiting for login method menu
119
+ // 'wait_url' → Enter sent for login method, waiting for OAuth URL
120
+ let stage = 'wait_theme'
121
+ let resolved = false
122
+ let lastContent = ''
123
+
124
+ const pollInterval = setInterval(() => {
125
+ if (resolved) return
126
+
127
+ if (!tmuxSessionExists()) {
128
+ resolved = true
129
+ clearInterval(pollInterval)
130
+ reject(new Error('Auth session ended unexpectedly'))
131
+ return
132
+ }
133
+
134
+ const content = captureTmuxPane()
135
+ const clean = stripAnsi(content)
136
+
137
+ // Check for URL at any stage
138
+ const url = extractUrlFromPane(content)
139
+ if (url) {
140
+ resolved = true
141
+ clearInterval(pollInterval)
142
+ console.log(`[Auth] URL found: ${url}`)
143
+ resolve(url)
144
+ return
145
+ }
146
+
147
+ if (stage === 'wait_theme') {
148
+ // Detect theme selection menu: look for "text style" or menu indicators
149
+ if (clean.match(/text\s*style|theme|dark|light/i) && clean.length > 20) {
150
+ console.log('[Auth] Theme menu detected, sending Enter #1')
151
+ try { execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`) } catch {}
152
+ stage = 'wait_login'
153
+ lastContent = clean
154
+ }
155
+ } else if (stage === 'wait_login') {
156
+ // Detect login method menu: look for "login" or "subscription" or content change
157
+ if (clean !== lastContent && (
158
+ clean.match(/login\s*method|subscription|account|how.*login/i) ||
159
+ clean.match(/anthropic|console|api\s*key/i) ||
160
+ // Content changed significantly after Enter — likely new menu
161
+ clean.length > lastContent.length + 10
162
+ )) {
163
+ console.log('[Auth] Login method menu detected, sending Enter #2')
164
+ try { execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`) } catch {}
165
+ stage = 'wait_url'
166
+ }
167
+ }
168
+ // stage === 'wait_url': just keep polling for URL (handled above)
169
+
170
+ }, 1000)
171
+
172
+ // Timeout after 60 seconds (longer for first-run initialization)
173
+ setTimeout(() => {
174
+ if (!resolved) {
175
+ resolved = true
176
+ clearInterval(pollInterval)
177
+ const content = captureTmuxPane()
178
+ const clean = stripAnsi(content).trim()
179
+ console.error(`[Auth] Timed out at stage=${stage}. Pane content: ${clean.slice(0, 500)}`)
180
+ reject(new Error(
181
+ `Timed out waiting for auth URL (stage: ${stage}). Output: ${clean.slice(0, 300) || '(none)'}`
182
+ ))
183
+ }
184
+ }, 60000)
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Submit an auth code to the running tmux session.
190
+ * Types the code and presses Enter.
191
+ */
192
+ function submitAuthCode(code) {
193
+ if (!tmuxSessionExists()) {
194
+ return { success: false, error: 'No auth session running' }
195
+ }
196
+
197
+ try {
198
+ console.log('[Auth] Submitting auth code to tmux session')
199
+ // Use send-keys with -l (literal) to avoid interpreting special chars
200
+ execSync(`tmux send-keys -t ${TMUX_SESSION} -l '${code.replace(/'/g, "'\\''")}'`)
201
+ execSync(`tmux send-keys -t ${TMUX_SESSION} Enter`)
202
+ return { success: true }
203
+ } catch (err) {
204
+ console.error(`[Auth] Failed to send code: ${err.message}`)
205
+ return { success: false, error: 'Failed to submit code to auth session' }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Register auth routes as Fastify plugin
211
+ * @param {import('fastify').FastifyInstance} fastify
212
+ */
213
+ async function authRoutes(fastify) {
214
+
215
+ // Start LLM authentication flow
216
+ fastify.post('/api/auth/start', async (request, reply) => {
217
+ if (!verifyToken(request)) {
218
+ reply.code(401)
219
+ return { success: false, error: 'Unauthorized' }
220
+ }
221
+
222
+ const { service } = request.body || {}
223
+ const targetService = service || 'claude'
224
+
225
+ if (targetService !== 'claude') {
226
+ reply.code(400)
227
+ return { success: false, error: `Unsupported service: ${targetService}` }
228
+ }
229
+
230
+ // Check if already authenticated
231
+ const services = getLlmServices()
232
+ const claude = services.find(s => s.name === 'claude')
233
+ if (claude && claude.authenticated) {
234
+ return { success: true, already_authenticated: true }
235
+ }
236
+
237
+ // Clean up any previous auth session and start fresh
238
+ if (authInProgress) {
239
+ console.log('[Auth] Cleaning up previous auth session')
240
+ releaseAuthLock()
241
+ }
242
+
243
+ authInProgress = true
244
+ // Safety timeout — release lock after 5 minutes
245
+ authLockTimer = setTimeout(releaseAuthLock, 300000)
246
+
247
+ console.log('[Auth] Starting Claude Code authentication flow')
248
+
249
+ try {
250
+ const authUrl = await startClaudeAuth()
251
+ console.log('[Auth] Auth URL obtained successfully')
252
+ return { success: true, auth_url: authUrl }
253
+ } catch (err) {
254
+ console.error(`[Auth] Failed: ${err.message}`)
255
+ releaseAuthLock()
256
+ return {
257
+ success: false,
258
+ error: err.message,
259
+ fallback: 'Open Terminal and run: claude auth login',
260
+ }
261
+ }
262
+ })
263
+
264
+ // Submit auth code to running session
265
+ fastify.post('/api/auth/code', async (request, reply) => {
266
+ if (!verifyToken(request)) {
267
+ reply.code(401)
268
+ return { success: false, error: 'Unauthorized' }
269
+ }
270
+
271
+ const { code } = request.body || {}
272
+ if (!code || typeof code !== 'string') {
273
+ reply.code(400)
274
+ return { success: false, error: 'Missing auth code' }
275
+ }
276
+
277
+ const result = submitAuthCode(code.trim())
278
+ if (!result.success) {
279
+ reply.code(409)
280
+ }
281
+ return result
282
+ })
283
+
284
+ // Logout from LLM service
285
+ fastify.post('/api/auth/logout', async (request, reply) => {
286
+ if (!verifyToken(request)) {
287
+ reply.code(401)
288
+ return { success: false, error: 'Unauthorized' }
289
+ }
290
+
291
+ const { service } = request.body || {}
292
+ const targetService = service || 'claude'
293
+
294
+ if (targetService !== 'claude') {
295
+ reply.code(400)
296
+ return { success: false, error: `Unsupported service: ${targetService}` }
297
+ }
298
+
299
+ // Delete credential files directly (more reliable than CLI which may be interactive)
300
+ const credPaths = [
301
+ path.join(config.HOME_DIR, '.claude', '.credentials.json'),
302
+ path.join(config.HOME_DIR, '.claude', 'credentials.json'),
303
+ ]
304
+
305
+ let deleted = 0
306
+ for (const p of credPaths) {
307
+ try {
308
+ if (fs.existsSync(p)) {
309
+ fs.unlinkSync(p)
310
+ console.log(`[Auth] Deleted: ${p}`)
311
+ deleted++
312
+ }
313
+ } catch (err) {
314
+ console.error(`[Auth] Failed to delete ${p}: ${err.message}`)
315
+ }
316
+ }
317
+
318
+ // Clear cached LLM status so next check reflects the change
319
+ clearLlmCache()
320
+
321
+ console.log(`[Auth] Logout completed (${deleted} credential files removed)`)
322
+ return { success: true }
323
+ })
324
+
325
+ // Get current LLM authentication status (for polling)
326
+ fastify.get('/api/auth/status', async (request, reply) => {
327
+ if (!verifyToken(request)) {
328
+ reply.code(401)
329
+ return { success: false, error: 'Unauthorized' }
330
+ }
331
+
332
+ // During active auth flow, bypass cache so credential changes are detected immediately
333
+ if (authInProgress) {
334
+ clearLlmCache()
335
+ }
336
+
337
+ const services = getLlmServices()
338
+
339
+ // Auto-release auth lock when Claude is now authenticated
340
+ if (authInProgress) {
341
+ const claude = services.find(s => s.name === 'claude')
342
+ if (claude && claude.authenticated) {
343
+ console.log('[Auth] Authentication detected, releasing auth lock')
344
+ releaseAuthLock()
345
+ }
346
+ }
347
+
348
+ return {
349
+ success: true,
350
+ services,
351
+ auth_in_progress: authInProgress,
352
+ }
353
+ })
354
+ }
355
+
356
+ module.exports = { authRoutes }
package/routes/chat.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Chat endpoints
3
+ *
4
+ * Provides streaming chat with Claude Code CLI using `--resume` sessions.
5
+ * Messages are sent via POST with SSE response for real-time streaming.
6
+ * Session state is persisted to local JSON via chat-store.js.
7
+ *
8
+ * Endpoints:
9
+ * POST /api/chat - Send message, get SSE stream
10
+ * GET /api/chat/session - Get active session (messages + session_id)
11
+ * POST /api/chat/clear - Clear session and start fresh
12
+ */
13
+
14
+ const { spawn } = require('child_process')
15
+ const fs = require('fs')
16
+ const path = require('path')
17
+ const { verifyToken } = require('../lib/auth')
18
+ const { config } = require('../config')
19
+ const chatStore = require('../chat-store')
20
+
21
+ /**
22
+ * Register chat routes as Fastify plugin
23
+ * @param {import('fastify').FastifyInstance} fastify
24
+ */
25
+ async function chatRoutes(fastify) {
26
+
27
+ // POST /api/chat - Send a message and get streaming response
28
+ fastify.post('/api/chat', async (request, reply) => {
29
+ if (!verifyToken(request)) {
30
+ reply.code(401)
31
+ return { success: false, error: 'Unauthorized' }
32
+ }
33
+
34
+ const { message, session_id, context } = request.body || {}
35
+
36
+ if (!message || typeof message !== 'string') {
37
+ reply.code(400)
38
+ return { success: false, error: 'message is required' }
39
+ }
40
+
41
+ // Build prompt — add context prefix when context is available (new or resumed)
42
+ const prompt = context
43
+ ? buildContextPrefix(message, context)
44
+ : message
45
+
46
+ // Store user message
47
+ const currentSessionId = session_id || null
48
+ if (currentSessionId) {
49
+ await chatStore.addMessage(currentSessionId, { role: 'user', content: message })
50
+ }
51
+
52
+ // Take over response handling from Fastify for SSE streaming
53
+ reply.hijack()
54
+
55
+ reply.raw.writeHead(200, {
56
+ 'Content-Type': 'text/event-stream',
57
+ 'Cache-Control': 'no-cache',
58
+ 'Connection': 'keep-alive',
59
+ })
60
+ reply.raw.flushHeaders()
61
+
62
+ try {
63
+ await streamClaudeResponse(reply.raw, prompt, currentSessionId)
64
+ } catch (err) {
65
+ console.error('[Chat] stream error:', err.message)
66
+ const errorEvent = JSON.stringify({ type: 'error', error: err.message })
67
+ reply.raw.write(`data: ${errorEvent}\n\n`)
68
+ }
69
+
70
+ reply.raw.end()
71
+ })
72
+
73
+ // GET /api/chat/session - Get active chat session
74
+ fastify.get('/api/chat/session', async (request, reply) => {
75
+ if (!verifyToken(request)) {
76
+ reply.code(401)
77
+ return { success: false, error: 'Unauthorized' }
78
+ }
79
+
80
+ const session = await chatStore.load()
81
+ if (!session) {
82
+ return { success: true, session: null }
83
+ }
84
+
85
+ return {
86
+ success: true,
87
+ session: {
88
+ session_id: session.session_id,
89
+ messages: session.messages,
90
+ created_at: session.created_at,
91
+ updated_at: session.updated_at,
92
+ },
93
+ }
94
+ })
95
+
96
+ // POST /api/chat/clear - Clear the active session
97
+ fastify.post('/api/chat/clear', async (request, reply) => {
98
+ if (!verifyToken(request)) {
99
+ reply.code(401)
100
+ return { success: false, error: 'Unauthorized' }
101
+ }
102
+
103
+ await chatStore.clear()
104
+ return { success: true }
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Build context prefix that tells Claude CLI where the user is on the HQ dashboard
110
+ * and how to fetch details via the `hq` helper command.
111
+ * No conversation history injection — Claude CLI handles that via --resume.
112
+ */
113
+ function buildContextPrefix(message, context) {
114
+ const parts = []
115
+
116
+ if (context) {
117
+ switch (context.type) {
118
+ case 'skill':
119
+ if (context.skillName) {
120
+ parts.push(
121
+ `ユーザーはHQダッシュボードでスキル「${context.skillName}」を閲覧しています。`,
122
+ `スキルの詳細を取得するには以下を実行してください:`,
123
+ ` hq fetch skill ${context.skillName}`,
124
+ `取得した内容をもとに回答してください。ローカルファイルを検索する必要はありません。`
125
+ )
126
+ }
127
+ break
128
+ case 'project':
129
+ if (context.projectId) {
130
+ const label = context.projectName
131
+ ? `プロジェクト「${context.projectName}」(ID: ${context.projectId})`
132
+ : `プロジェクト (ID: ${context.projectId})`
133
+ parts.push(
134
+ `ユーザーはHQダッシュボードで${label}を閲覧しています。`,
135
+ `プロジェクト情報を取得するには以下を実行してください:`,
136
+ ` hq fetch project ${context.projectId}`,
137
+ ` hq fetch project-context ${context.projectId}`,
138
+ `取得した内容をもとに回答してください。`
139
+ )
140
+ }
141
+ break
142
+ case 'workflow':
143
+ if (context.projectId) {
144
+ const label = context.workflowName
145
+ ? `ワークフロー「${context.workflowName}」`
146
+ : 'ワークフロー'
147
+ parts.push(
148
+ `ユーザーはHQダッシュボードで${label}を閲覧しています。`,
149
+ `ワークフロー情報を取得するには以下を実行してください:`,
150
+ ` hq fetch workflow ${context.workflowName || context.workflowId}`,
151
+ `プロジェクトコンテキスト:`,
152
+ ` hq fetch project-context ${context.projectId}`,
153
+ `取得した内容をもとに回答してください。`
154
+ )
155
+ }
156
+ break
157
+ }
158
+ }
159
+
160
+ if (parts.length > 0) {
161
+ return `${parts.join('\n')}\n\n${message}`
162
+ }
163
+
164
+ return message
165
+ }
166
+
167
+ /**
168
+ * Stream Claude Code CLI output as SSE events.
169
+ * Uses --resume to continue existing sessions.
170
+ */
171
+ function streamClaudeResponse(res, prompt, sessionId) {
172
+ return new Promise((resolve, reject) => {
173
+ const claudePath = path.join(config.HOME_DIR, '.local', 'bin', 'claude')
174
+ const claudeBin = fs.existsSync(claudePath) ? claudePath : 'claude'
175
+
176
+ const extendedPath = [
177
+ `${config.HOME_DIR}/bin`,
178
+ `${config.HOME_DIR}/.npm-global/bin`,
179
+ `${config.HOME_DIR}/.local/bin`,
180
+ `${config.HOME_DIR}/.claude/bin`,
181
+ '/usr/local/bin',
182
+ '/usr/bin',
183
+ '/bin',
184
+ ].join(':')
185
+
186
+ // Build CLI args
187
+ const args = [
188
+ '-p',
189
+ '--verbose',
190
+ '--output-format', 'stream-json',
191
+ '--max-turns', '10',
192
+ ]
193
+
194
+ // Resume existing session
195
+ if (sessionId) {
196
+ args.push('--resume', sessionId)
197
+ }
198
+
199
+ args.push(prompt)
200
+
201
+ console.log(`[Chat] spawning: ${claudeBin} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
202
+
203
+ const child = spawn(claudeBin, args, {
204
+ cwd: config.HOME_DIR,
205
+ stdio: ['pipe', 'pipe', 'pipe'],
206
+ timeout: 300000, // 5 min
207
+ env: {
208
+ ...process.env,
209
+ HOME: config.HOME_DIR,
210
+ PATH: extendedPath,
211
+ DISPLAY: ':99',
212
+ },
213
+ })
214
+
215
+ // Close stdin immediately so CLI doesn't wait for input
216
+ child.stdin.end()
217
+
218
+ console.log(`[Chat] child PID: ${child.pid}`)
219
+
220
+ let fullResponse = ''
221
+ let stderrBuffer = ''
222
+ let lineBuffer = ''
223
+ let resolvedSessionId = sessionId || null
224
+
225
+ child.stdout.on('data', (data) => {
226
+ lineBuffer += data.toString()
227
+ const parts = lineBuffer.split('\n')
228
+ // Keep the last (potentially incomplete) line in the buffer
229
+ lineBuffer = parts.pop() || ''
230
+
231
+ for (const line of parts) {
232
+ if (!line.trim()) continue
233
+ try {
234
+ const parsed = JSON.parse(line)
235
+
236
+ // system init event — capture session_id
237
+ if (parsed.type === 'system' && parsed.session_id) {
238
+ resolvedSessionId = parsed.session_id
239
+ console.log(`[Chat] session_id: ${resolvedSessionId}`)
240
+ }
241
+
242
+ // assistant message content blocks
243
+ if (parsed.type === 'assistant' && parsed.message) {
244
+ for (const block of (parsed.message.content || [])) {
245
+ if (block.type === 'text') {
246
+ fullResponse += block.text
247
+ const event = JSON.stringify({ type: 'text', content: block.text })
248
+ res.write(`data: ${event}\n\n`)
249
+ }
250
+ }
251
+ } else if (parsed.type === 'content_block_delta') {
252
+ const delta = parsed.delta?.text || ''
253
+ if (delta) {
254
+ fullResponse += delta
255
+ const event = JSON.stringify({ type: 'delta', content: delta })
256
+ res.write(`data: ${event}\n\n`)
257
+ }
258
+ } else if (parsed.type === 'result') {
259
+ // result event — send as 'result' type for frontend to use as final text
260
+ const resultText = parsed.result || ''
261
+ if (resultText) {
262
+ const event = JSON.stringify({ type: 'result', content: resultText })
263
+ res.write(`data: ${event}\n\n`)
264
+ fullResponse = resultText
265
+ }
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
+ }
272
+ } catch {
273
+ // Non-JSON line — ignore
274
+ console.warn(`[Chat] ignoring non-JSON line: ${line.substring(0, 80)}`)
275
+ }
276
+ }
277
+ })
278
+
279
+ child.stderr.on('data', (data) => {
280
+ const text = data.toString()
281
+ stderrBuffer += text
282
+ console.error(`[Chat] stderr: ${text}`)
283
+ })
284
+
285
+ child.on('close', async (code) => {
286
+ // Store messages in chat-store
287
+ if (resolvedSessionId) {
288
+ // If this was a new session, also store the user message now
289
+ if (!sessionId) {
290
+ await chatStore.addMessage(resolvedSessionId, { role: 'user', content: prompt })
291
+ }
292
+ // Store assistant response
293
+ if (fullResponse) {
294
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse })
295
+ }
296
+ }
297
+
298
+ // If exit code is non-zero and no response was generated, send error
299
+ if (code !== 0 && !fullResponse) {
300
+ const errorMsg = stderrBuffer.trim() || `Claude CLI exited with code ${code}`
301
+ console.error(`[Chat] CLI failed (exit ${code}): ${errorMsg}`)
302
+ const errorEvent = JSON.stringify({ type: 'error', error: errorMsg })
303
+ res.write(`data: ${errorEvent}\n\n`)
304
+ }
305
+
306
+ const doneEvent = JSON.stringify({
307
+ type: 'done',
308
+ session_id: resolvedSessionId,
309
+ })
310
+ res.write(`data: ${doneEvent}\n\n`)
311
+ resolve()
312
+ })
313
+
314
+ child.on('error', (err) => {
315
+ console.error(`[Chat] spawn error: ${err.message}`)
316
+ const errorEvent = JSON.stringify({ type: 'error', error: `Failed to start Claude CLI: ${err.message}` })
317
+ res.write(`data: ${errorEvent}\n\n`)
318
+ reject(err)
319
+ })
320
+
321
+ // Handle client disconnect
322
+ res.on('close', () => {
323
+ child.kill('SIGTERM')
324
+ })
325
+ })
326
+ }
327
+
328
+ module.exports = { chatRoutes }