@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/.env.example +2 -2
- package/README.md +1 -1
- package/api.js +1 -1
- package/config.js +17 -3
- package/docs/api-reference.md +1 -1
- package/docs/task-guides.md +7 -7
- package/lib/llm-checker.js +49 -6
- package/minion-cli.sh +226 -11
- package/package.json +1 -1
- package/roles/pm.md +1 -1
- package/routes/auth.js +9 -409
- package/routes/chat.js +77 -16
- package/routes/config.js +252 -0
- package/routes/health.js +2 -1
- package/routes/index.js +7 -1
- package/routes/routines.js +41 -1
- package/routes/skills.js +22 -6
- package/routine-runner.js +6 -2
- package/rules/core.md +2 -2
- package/skills/execution-report/SKILL.md +1 -1
- package/workflow-runner.js +14 -5
package/routes/auth.js
CHANGED
|
@@ -1,272 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* LLM authentication status endpoint
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
216
|
+
function streamLlmResponse(res, prompt, sessionId) {
|
|
172
217
|
return new Promise((resolve, reject) => {
|
|
173
|
-
const
|
|
174
|
-
|
|
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: ${
|
|
251
|
+
console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
|
|
202
252
|
|
|
203
|
-
const child = spawn(
|
|
253
|
+
const child = spawn(binary, args, {
|
|
204
254
|
cwd: config.HOME_DIR,
|
|
205
255
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
206
|
-
timeout:
|
|
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`)
|