@geekbeer/minion 2.11.1 → 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/bin/hq ADDED
@@ -0,0 +1,108 @@
1
+ #!/bin/bash
2
+ # HQ API helper for minion chat context
3
+ #
4
+ # Fetches resource details from the HQ server API.
5
+ # Used by Claude CLI during chat to retrieve information about
6
+ # skills, workflows, and projects that the user is viewing on the dashboard.
7
+ #
8
+ # Environment variables (inherited from minion server):
9
+ # HQ_URL - HQ server URL (e.g., https://minion-agent.com)
10
+ # API_TOKEN - Minion API token for authentication
11
+ #
12
+ # Usage:
13
+ # hq fetch skill <name> - Get skill details (content, description, files)
14
+ # hq fetch workflow <name> - Get workflow details (pipeline, cron, etc.)
15
+ # hq fetch project <id> - Get project info (name, description, role)
16
+ # hq fetch project-context <id> - Get project context (shared Markdown document)
17
+
18
+ set -euo pipefail
19
+
20
+ # Validate required environment variables
21
+ if [ -z "${HQ_URL:-}" ]; then
22
+ echo "Error: HQ_URL is not set" >&2
23
+ exit 1
24
+ fi
25
+
26
+ if [ -z "${API_TOKEN:-}" ]; then
27
+ echo "Error: API_TOKEN is not set" >&2
28
+ exit 1
29
+ fi
30
+
31
+ BASE_URL="${HQ_URL}/api/minion"
32
+
33
+ # Pretty-print JSON if jq is available, otherwise output raw
34
+ format_json() {
35
+ if command -v jq &>/dev/null; then
36
+ jq .
37
+ else
38
+ cat
39
+ fi
40
+ }
41
+
42
+ fetch_resource() {
43
+ local url="$1"
44
+ local response
45
+ local http_code
46
+
47
+ # Fetch with HTTP status code
48
+ response=$(curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_TOKEN" "$url")
49
+ http_code=$(echo "$response" | tail -1)
50
+ body=$(echo "$response" | sed '$d')
51
+
52
+ if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
53
+ echo "$body" | format_json
54
+ else
55
+ echo "Error: HQ API returned HTTP $http_code" >&2
56
+ echo "$body" >&2
57
+ exit 1
58
+ fi
59
+ }
60
+
61
+ # Main command dispatch
62
+ case "${1:-}" in
63
+ fetch)
64
+ resource="${2:-}"
65
+ identifier="${3:-}"
66
+
67
+ if [ -z "$resource" ] || [ -z "$identifier" ]; then
68
+ echo "Usage: hq fetch {skill|workflow|project|project-context} <identifier>" >&2
69
+ exit 1
70
+ fi
71
+
72
+ case "$resource" in
73
+ skill)
74
+ fetch_resource "$BASE_URL/skills/$identifier"
75
+ ;;
76
+ workflow)
77
+ fetch_resource "$BASE_URL/workflows/$identifier"
78
+ ;;
79
+ project)
80
+ # Fetch all projects and filter by ID
81
+ response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$BASE_URL/me/projects")
82
+ if command -v jq &>/dev/null; then
83
+ echo "$response" | jq --arg id "$identifier" '.projects[] | select(.id == $id)'
84
+ else
85
+ echo "$response" | format_json
86
+ fi
87
+ ;;
88
+ project-context)
89
+ fetch_resource "$BASE_URL/me/project/$identifier/context"
90
+ ;;
91
+ *)
92
+ echo "Unknown resource: $resource" >&2
93
+ echo "Usage: hq fetch {skill|workflow|project|project-context} <identifier>" >&2
94
+ exit 1
95
+ ;;
96
+ esac
97
+ ;;
98
+ *)
99
+ echo "HQ API helper for minion chat" >&2
100
+ echo "" >&2
101
+ echo "Usage:" >&2
102
+ echo " hq fetch skill <name> - Get skill details" >&2
103
+ echo " hq fetch workflow <name> - Get workflow details" >&2
104
+ echo " hq fetch project <id> - Get project info" >&2
105
+ echo " hq fetch project-context <id> - Get project context" >&2
106
+ exit 1
107
+ ;;
108
+ esac
package/chat-store.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Chat Session Store
3
+ * Persists the active chat session (session_id + messages) to local JSON file.
4
+ * One active session per minion. Claude CLI manages conversation context via --resume.
5
+ */
6
+
7
+ const fs = require('fs').promises
8
+ const path = require('path')
9
+
10
+ const { config } = require('./config')
11
+
12
+ const MAX_MESSAGES = 100
13
+
14
+ /**
15
+ * Get chat session file path
16
+ * Uses /opt/minion-agent/ if available, otherwise home dir
17
+ */
18
+ function getFilePath() {
19
+ const optPath = '/opt/minion-agent/chat-session.json'
20
+ try {
21
+ require('fs').accessSync(path.dirname(optPath))
22
+ return optPath
23
+ } catch {
24
+ return path.join(config.HOME_DIR, 'chat-session.json')
25
+ }
26
+ }
27
+
28
+ const SESSION_FILE = getFilePath()
29
+
30
+ /**
31
+ * Load the active chat session
32
+ * @returns {Promise<object|null>} Session object or null if none exists
33
+ */
34
+ async function load() {
35
+ try {
36
+ const data = await fs.readFile(SESSION_FILE, 'utf-8')
37
+ return JSON.parse(data)
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') {
40
+ return null
41
+ }
42
+ console.error(`[ChatStore] Failed to load session: ${err.message}`)
43
+ return null
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Save the active chat session
49
+ * @param {object} session - Session object
50
+ */
51
+ async function save(session) {
52
+ try {
53
+ await fs.writeFile(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8')
54
+ } catch (err) {
55
+ console.error(`[ChatStore] Failed to save session: ${err.message}`)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Add a message to the active session
61
+ * Creates a new session if none exists
62
+ * @param {string} sessionId - Claude CLI session ID
63
+ * @param {{ role: string, content: string }} msg - Message to add
64
+ */
65
+ async function addMessage(sessionId, msg) {
66
+ let session = await load()
67
+
68
+ // If session_id changed, start a new session
69
+ if (!session || session.session_id !== sessionId) {
70
+ session = {
71
+ session_id: sessionId,
72
+ messages: [],
73
+ created_at: Date.now(),
74
+ updated_at: Date.now(),
75
+ }
76
+ }
77
+
78
+ session.messages.push({
79
+ role: msg.role,
80
+ content: msg.content,
81
+ timestamp: Date.now(),
82
+ })
83
+ session.updated_at = Date.now()
84
+
85
+ // Prune old messages
86
+ if (session.messages.length > MAX_MESSAGES) {
87
+ session.messages = session.messages.slice(-MAX_MESSAGES)
88
+ }
89
+
90
+ await save(session)
91
+ }
92
+
93
+ /**
94
+ * Clear the active session
95
+ */
96
+ async function clear() {
97
+ try {
98
+ await fs.unlink(SESSION_FILE)
99
+ } catch (err) {
100
+ if (err.code !== 'ENOENT') {
101
+ console.error(`[ChatStore] Failed to clear session: ${err.message}`)
102
+ }
103
+ }
104
+ }
105
+
106
+ module.exports = { load, save, addMessage, clear }
@@ -111,4 +111,10 @@ function getLlmServices() {
111
111
  return services
112
112
  }
113
113
 
114
- module.exports = { getLlmServices }
114
+ /** Clear the cached LLM service status */
115
+ function clearLlmCache() {
116
+ cachedResult = null
117
+ cachedAt = 0
118
+ }
119
+
120
+ module.exports = { getLlmServices, clearLlmCache }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.11.1",
3
+ "version": "2.16.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "minion-cli": "./minion-cli.sh"
7
+ "minion-cli": "./minion-cli.sh",
8
+ "hq": "./bin/hq"
8
9
  },
9
10
  "files": [
10
11
  "server.js",
@@ -16,6 +17,7 @@
16
17
  "routine-runner.js",
17
18
  "routine-store.js",
18
19
  "execution-store.js",
20
+ "chat-store.js",
19
21
  "lib/",
20
22
  "routes/",
21
23
  "skills/",
@@ -23,6 +25,7 @@
23
25
  "roles/",
24
26
  "docs/",
25
27
  "settings/",
28
+ "bin/",
26
29
  "minion-cli.sh",
27
30
  ".env.example"
28
31
  ],
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 }
package/routes/index.js CHANGED
@@ -51,6 +51,15 @@
51
51
  *
52
52
  * Directives (routes/directives.js)
53
53
  * POST /api/directive - Receive and execute a temp skill directive (auth required)
54
+ *
55
+ * Auth (routes/auth.js)
56
+ * POST /api/auth/start - Start LLM authentication flow (auth required)
57
+ * GET /api/auth/status - Get LLM authentication status (auth required)
58
+ *
59
+ * Chat (routes/chat.js)
60
+ * POST /api/chat - Send message, get SSE stream (auth required)
61
+ * GET /api/chat/session - Get active session (auth required)
62
+ * POST /api/chat/clear - Clear session (auth required)
54
63
  * ─────────────────────────────────────────────────────────────────────────────
55
64
  */
56
65
 
@@ -62,6 +71,8 @@ const { routineRoutes } = require('./routines')
62
71
  const { terminalRoutes } = require('./terminal')
63
72
  const { fileRoutes } = require('./files')
64
73
  const { directiveRoutes } = require('./directives')
74
+ const { authRoutes } = require('./auth')
75
+ const { chatRoutes } = require('./chat')
65
76
 
66
77
  /**
67
78
  * Register all routes with Fastify instance
@@ -76,6 +87,8 @@ async function registerRoutes(fastify) {
76
87
  await fastify.register(terminalRoutes)
77
88
  await fastify.register(fileRoutes)
78
89
  await fastify.register(directiveRoutes)
90
+ await fastify.register(authRoutes)
91
+ await fastify.register(chatRoutes)
79
92
  }
80
93
 
81
94
  module.exports = {