@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 +108 -0
- package/chat-store.js +106 -0
- package/lib/llm-checker.js +7 -1
- package/package.json +5 -2
- package/routes/auth.js +356 -0
- package/routes/chat.js +328 -0
- package/routes/index.js +13 -0
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 }
|
package/lib/llm-checker.js
CHANGED
|
@@ -111,4 +111,10 @@ function getLlmServices() {
|
|
|
111
111
|
return services
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
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.
|
|
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 = {
|