@geekbeer/minion 2.60.0 → 2.67.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/core/api.js CHANGED
@@ -86,10 +86,120 @@ async function sendHeartbeat(data) {
86
86
  })
87
87
  }
88
88
 
89
+ /**
90
+ * Create a project thread (help or discussion) on HQ.
91
+ * @param {object} data - { project_id, title, description, thread_type?, mentions?, context? }
92
+ * @returns {Promise<{ thread: object }>}
93
+ */
94
+ async function createHelpThread(data) {
95
+ return request('/help-threads', {
96
+ method: 'POST',
97
+ body: JSON.stringify(data),
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Get open help threads in this minion's projects.
103
+ * @returns {Promise<{ threads: object[] }>}
104
+ */
105
+ async function getOpenHelpThreads() {
106
+ return request('/help-threads/open')
107
+ }
108
+
109
+ /**
110
+ * Get a help thread by ID with messages.
111
+ * @param {string} threadId
112
+ * @returns {Promise<{ thread: object, messages: object[] }>}
113
+ */
114
+ async function getHelpThread(threadId) {
115
+ return request(`/help-threads/${threadId}`)
116
+ }
117
+
118
+ /**
119
+ * Post a message to a help thread.
120
+ * @param {string} threadId
121
+ * @param {object} data - { content, attachments?, mentions? }
122
+ */
123
+ async function postHelpMessage(threadId, data) {
124
+ return request(`/help-threads/${threadId}/messages`, {
125
+ method: 'POST',
126
+ body: JSON.stringify(data),
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Resolve a help thread.
132
+ * @param {string} threadId
133
+ * @param {string} resolution
134
+ */
135
+ async function resolveHelpThread(threadId, resolution) {
136
+ return request(`/help-threads/${threadId}/resolve`, {
137
+ method: 'PATCH',
138
+ body: JSON.stringify({ resolution }),
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Cancel a help thread.
144
+ * @param {string} threadId
145
+ * @param {string} [reason]
146
+ */
147
+ async function cancelHelpThread(threadId, reason) {
148
+ return request(`/help-threads/${threadId}/cancel`, {
149
+ method: 'PATCH',
150
+ body: JSON.stringify({ reason: reason || null }),
151
+ })
152
+ }
153
+
154
+ /**
155
+ * Permanently delete a help thread (PM only).
156
+ * @param {string} threadId
157
+ */
158
+ async function deleteHelpThread(threadId) {
159
+ return request(`/help-threads/${threadId}`, {
160
+ method: 'DELETE',
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Create a project memory entry.
166
+ * @param {object} data - { project_id, title, content, category, tags?, source_thread_id? }
167
+ */
168
+ async function createProjectMemory(data) {
169
+ return request('/project-memories', {
170
+ method: 'POST',
171
+ body: JSON.stringify(data),
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Search project memories.
177
+ * @param {string} projectId
178
+ * @param {object} [opts] - { search?, category?, tags?, status? }
179
+ */
180
+ async function searchProjectMemories(projectId, opts = {}) {
181
+ const params = new URLSearchParams({ project_id: projectId })
182
+ if (opts.search) params.set('search', opts.search)
183
+ if (opts.category) params.set('category', opts.category)
184
+ if (opts.tags) params.set('tags', opts.tags.join(','))
185
+ if (opts.status) params.set('status', opts.status)
186
+ return request(`/project-memories?${params.toString()}`)
187
+ }
188
+
89
189
  module.exports = {
90
190
  request,
91
191
  reportExecution,
92
192
  reportStepComplete,
93
193
  reportIssue,
94
194
  sendHeartbeat,
195
+ createHelpThread,
196
+ getOpenHelpThreads,
197
+ getHelpThread,
198
+ postHelpMessage,
199
+ resolveHelpThread,
200
+
201
+ cancelHelpThread,
202
+ deleteHelpThread,
203
+ createProjectMemory,
204
+ searchProjectMemories,
95
205
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Config Warnings - checks for missing required configurations
3
+ *
4
+ * Computes a list of warning objects that indicate missing or misconfigured
5
+ * settings that prevent the minion from functioning properly.
6
+ * Results are included in heartbeat payloads so HQ can display them on the dashboard.
7
+ *
8
+ * Warning categories:
9
+ * - llm_not_authenticated: No LLM service is authenticated (Claude/Gemini/Codex)
10
+ * - llm_command_not_set: LLM_COMMAND is not configured (required for workflow/routine execution)
11
+ * - tunnel_not_running: Cloudflare tunnel is not running (required for VNC/terminal access)
12
+ */
13
+
14
+ const { execSync } = require('child_process')
15
+ const { config } = require('../config')
16
+ const { getLlmServices, isLlmCommandConfigured } = require('./llm-checker')
17
+ const { IS_WINDOWS } = require('./platform')
18
+
19
+ /**
20
+ * Check if Cloudflare tunnel is running.
21
+ * @returns {boolean}
22
+ */
23
+ function isTunnelRunning() {
24
+ try {
25
+ if (IS_WINDOWS) {
26
+ const out = execSync('tasklist /FI "IMAGENAME eq cloudflared.exe" /NH', {
27
+ encoding: 'utf-8',
28
+ timeout: 5000,
29
+ stdio: 'pipe',
30
+ })
31
+ return out.toLowerCase().includes('cloudflared.exe')
32
+ } else {
33
+ execSync('pgrep -f "cloudflared"', {
34
+ encoding: 'utf-8',
35
+ timeout: 5000,
36
+ stdio: 'pipe',
37
+ })
38
+ return true
39
+ }
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Compute config warnings for the current minion state.
47
+ * Each warning is an object with:
48
+ * - code: string identifier (e.g. 'llm_not_authenticated')
49
+ * - message: human-readable description (Japanese)
50
+ *
51
+ * @returns {{ code: string, message: string }[]}
52
+ */
53
+ function getConfigWarnings() {
54
+ const warnings = []
55
+
56
+ // Check LLM authentication
57
+ const services = getLlmServices()
58
+ const hasAuthenticatedLlm = services.some(s => s.authenticated)
59
+ if (!hasAuthenticatedLlm) {
60
+ warnings.push({
61
+ code: 'llm_not_authenticated',
62
+ message: 'LLM\u304C\u672A\u8A8D\u8A3C\u3067\u3059\u3002Claude/Gemini/Codex\u306E\u3044\u305A\u308C\u304B\u3092\u8A8D\u8A3C\u3057\u3066\u304F\u3060\u3055\u3044',
63
+ })
64
+ }
65
+
66
+ // Check LLM_COMMAND
67
+ if (!isLlmCommandConfigured()) {
68
+ warnings.push({
69
+ code: 'llm_command_not_set',
70
+ message: 'LLM_COMMAND\u304C\u672A\u8A2D\u5B9A\u3067\u3059\u3002\u30EF\u30FC\u30AF\u30D5\u30ED\u30FC/\u30EB\u30FC\u30C6\u30A3\u30F3\u5B9F\u884C\u306B\u5FC5\u8981\u3067\u3059',
71
+ })
72
+ }
73
+
74
+ // Check tunnel (only for HQ-connected minions)
75
+ if (config.HQ_URL && !isTunnelRunning()) {
76
+ warnings.push({
77
+ code: 'tunnel_not_running',
78
+ message: 'Cloudflare\u30C8\u30F3\u30CD\u30EB\u304C\u505C\u6B62\u3057\u3066\u3044\u307E\u3059\u3002VNC/\u30BF\u30FC\u30DF\u30CA\u30EB\u63A5\u7D9A\u304C\u3067\u304D\u307E\u305B\u3093',
79
+ })
80
+ }
81
+
82
+ return warnings
83
+ }
84
+
85
+ module.exports = { getConfigWarnings }
@@ -156,13 +156,9 @@ async function runReflection() {
156
156
  * @returns {{ enabled: boolean, reflection_time: string, timezone: string, next_run: string|null }}
157
157
  */
158
158
  function getStatus() {
159
- const reflectionTime = config.REFLECTION_TIME || ''
160
159
  const nextRun = cronJob ? cronJob.nextRun() : null
161
-
162
160
  return {
163
- enabled: !!cronJob,
164
- reflection_time: reflectionTime,
165
- timezone: getSystemTimezone(),
161
+ running: !!cronJob,
166
162
  next_run: nextRun ? nextRun.toISOString() : null,
167
163
  }
168
164
  }
@@ -22,6 +22,9 @@ const POLL_INTERVAL_MS = 30_000
22
22
  let polling = false
23
23
  let pollTimer = null
24
24
 
25
+ // Last successful poll timestamp
26
+ let lastPollAt = null
27
+
25
28
  // Track revisions being processed to avoid duplicate handling
26
29
  const processingRevisions = new Set()
27
30
 
@@ -63,6 +66,7 @@ async function pollOnce() {
63
66
  }
64
67
  } finally {
65
68
  polling = false
69
+ lastPollAt = new Date().toISOString()
66
70
  }
67
71
  }
68
72
 
@@ -249,4 +253,11 @@ function stop() {
249
253
  }
250
254
  }
251
255
 
252
- module.exports = { start, stop, pollOnce }
256
+ function getStatus() {
257
+ return {
258
+ running: pollTimer !== null,
259
+ last_poll_at: lastPollAt,
260
+ }
261
+ }
262
+
263
+ module.exports = { start, stop, pollOnce, getStatus }
@@ -33,6 +33,9 @@ let pollTimer = null
33
33
  // Track currently executing step to avoid double-dispatch
34
34
  let activeStepExecutionId = null
35
35
 
36
+ // Last successful poll timestamp
37
+ let lastPollAt = null
38
+
36
39
  /**
37
40
  * Poll HQ for pending steps and execute them.
38
41
  */
@@ -73,6 +76,7 @@ async function pollOnce() {
73
76
  }
74
77
  } finally {
75
78
  polling = false
79
+ lastPollAt = new Date().toISOString()
76
80
  }
77
81
  }
78
82
 
@@ -224,4 +228,11 @@ function stop() {
224
228
  }
225
229
  }
226
230
 
227
- module.exports = { start, stop, pollOnce }
231
+ function getStatus() {
232
+ return {
233
+ running: pollTimer !== null,
234
+ last_poll_at: lastPollAt,
235
+ }
236
+ }
237
+
238
+ module.exports = { start, stop, pollOnce, getStatus }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Project Thread Watcher
3
+ *
4
+ * Polling daemon that monitors open help/discussion threads in the minion's
5
+ * projects. Uses LLM to decide whether to participate in conversations.
6
+ *
7
+ * Token-saving strategies:
8
+ * 1. Read tracking: only evaluate threads with new messages since last check
9
+ * 2. Mention targeting: if thread/message has mentions, only targeted minions evaluate
10
+ * 3. Rate limiting: max 1 LLM evaluation per thread per 5 minutes
11
+ *
12
+ * Flow per poll cycle:
13
+ * 1. GET /api/minion/help-threads/open → list of open threads
14
+ * 2. For each thread with new activity:
15
+ * a. Check mentions → if mentioned, must evaluate
16
+ * b. Check read state → skip if no new messages
17
+ * c. Fetch thread detail + messages
18
+ * d. LLM evaluates relevance + generates response if appropriate
19
+ */
20
+
21
+ const { config, isHqConfigured, isLlmConfigured } = require('../config')
22
+ const api = require('../api')
23
+
24
+ // Poll every 15 seconds
25
+ const POLL_INTERVAL_MS = 15_000
26
+
27
+ // Minimum interval between LLM evaluations for the same thread (5 minutes)
28
+ const EVAL_COOLDOWN_MS = 5 * 60 * 1000
29
+
30
+ // Prevent concurrent poll cycles
31
+ let polling = false
32
+ let pollTimer = null
33
+
34
+ // Injected LLM function (set via start())
35
+ let llmCallFn = null
36
+
37
+ // Last successful poll timestamp
38
+ let lastPollAt = null
39
+
40
+ // Read tracking: threadId → { lastMessageCount, lastEvalAt }
41
+ const readState = new Map()
42
+
43
+ /**
44
+ * Get minion's role and project memberships for context.
45
+ * Cached per poll cycle.
46
+ */
47
+ let cachedProjects = null
48
+ let cachedProjectsAt = 0
49
+
50
+ async function getMyProjects() {
51
+ // Cache for 60 seconds
52
+ if (cachedProjects && Date.now() - cachedProjectsAt < 60_000) {
53
+ return cachedProjects
54
+ }
55
+ try {
56
+ const data = await api.request('/me/projects')
57
+ cachedProjects = data.projects || []
58
+ cachedProjectsAt = Date.now()
59
+ return cachedProjects
60
+ } catch {
61
+ return cachedProjects || []
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if this minion is mentioned in a thread or its messages.
67
+ * Mention formats: 'role:engineer', 'role:pm', 'minion:<minion_id>'
68
+ *
69
+ * @param {object} thread
70
+ * @param {object[]} messages - Only new messages to check
71
+ * @param {string} myRole - This minion's role in the project
72
+ * @returns {boolean}
73
+ */
74
+ function isMentioned(thread, messages, myRole) {
75
+ const myMinionId = config.MINION_ID
76
+ const targets = [
77
+ `role:${myRole}`,
78
+ `minion:${myMinionId}`,
79
+ ]
80
+
81
+ // Check thread-level mentions
82
+ if (thread.mentions?.some(m => targets.includes(m))) return true
83
+
84
+ // Check message-level mentions
85
+ for (const msg of messages) {
86
+ if (msg.mentions?.some(m => targets.includes(m))) return true
87
+ }
88
+
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * Poll for open threads and evaluate new activity.
94
+ */
95
+ async function pollOnce() {
96
+ if (!isHqConfigured()) return
97
+ if (polling) return
98
+
99
+ polling = true
100
+ try {
101
+ const data = await api.request('/help-threads/open')
102
+
103
+ if (!data.threads || data.threads.length === 0) {
104
+ return
105
+ }
106
+
107
+ const now = Date.now()
108
+ const myProjects = await getMyProjects()
109
+
110
+ for (const thread of data.threads) {
111
+ try {
112
+ await processThread(thread, myProjects, now)
113
+ } catch (err) {
114
+ console.error(`[ThreadWatcher] Error processing thread ${thread.id}: ${err.message}`)
115
+ }
116
+ }
117
+ } catch (err) {
118
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
119
+ // HQ unreachable — silent retry
120
+ } else {
121
+ console.error(`[ThreadWatcher] Poll error: ${err.message}`)
122
+ }
123
+ } finally {
124
+ polling = false
125
+ lastPollAt = new Date().toISOString()
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Process a single thread: check for new activity and evaluate if needed.
131
+ */
132
+ async function processThread(thread, myProjects, now) {
133
+ // Find my role in this project
134
+ const myProject = myProjects.find(p => p.id === thread.project_id)
135
+ if (!myProject) return // Not a member
136
+
137
+ const state = readState.get(thread.id) || { lastMessageCount: 0, lastEvalAt: 0 }
138
+
139
+ // Fetch thread detail to get message count and check for new messages
140
+ let detail
141
+ try {
142
+ detail = await api.request(`/help-threads/${thread.id}`)
143
+ } catch {
144
+ return
145
+ }
146
+
147
+ const messages = detail.messages || []
148
+ const messageCount = messages.length
149
+
150
+ // Skip if no new messages since last check
151
+ if (messageCount <= state.lastMessageCount) {
152
+ return
153
+ }
154
+
155
+ // New messages detected
156
+ const newMessages = messages.slice(state.lastMessageCount)
157
+
158
+ // Update read state (message count only — eval timestamp updated after LLM call)
159
+ readState.set(thread.id, { ...state, lastMessageCount: messageCount })
160
+
161
+ // Check mentions first (no LLM needed to decide relevance)
162
+ const mentioned = isMentioned(thread, newMessages, myProject.role)
163
+
164
+ // If not mentioned and cooldown not expired, skip LLM evaluation
165
+ if (!mentioned && now - state.lastEvalAt < EVAL_COOLDOWN_MS) {
166
+ return
167
+ }
168
+
169
+ // If LLM is not configured, fall back to memory-only matching
170
+ if (!llmCallFn || !isLlmConfigured()) {
171
+ await fallbackMemoryMatch(thread, messages)
172
+ readState.set(thread.id, { lastMessageCount: messageCount, lastEvalAt: now })
173
+ return
174
+ }
175
+
176
+ // LLM evaluation
177
+ await evaluateWithLlm(thread, detail.thread, messages, newMessages, myProject, mentioned)
178
+ readState.set(thread.id, { lastMessageCount: messageCount, lastEvalAt: now })
179
+ }
180
+
181
+ /**
182
+ * Use LLM to evaluate thread and potentially respond.
183
+ */
184
+ async function evaluateWithLlm(threadSummary, threadDetail, allMessages, newMessages, myProject, mentioned) {
185
+ // Build conversation context (limit to last 20 messages to control tokens)
186
+ const recentMessages = allMessages.slice(-20)
187
+ const messageHistory = recentMessages
188
+ .map(m => {
189
+ const sender = m.sender_minion_id
190
+ ? (m.sender_minion_id === config.MINION_ID ? 'You' : `Minion(${m.sender_minion_id.slice(0, 8)})`)
191
+ : 'User'
192
+ return `[${sender}] ${m.content}`
193
+ })
194
+ .join('\n')
195
+
196
+ const threadType = threadDetail.thread_type || 'help'
197
+ const mentionNote = mentioned
198
+ ? '\n\nあなたはこのスレッドでメンションされています。必ず返信してください。'
199
+ : ''
200
+
201
+ // Extract optional context metadata
202
+ const ctx = threadDetail.context || {}
203
+ const contextInfo = [
204
+ ctx.category ? `カテゴリ: ${ctx.category}` : '',
205
+ ctx.attempted_resolution ? `試行済み: ${ctx.attempted_resolution}` : '',
206
+ ].filter(Boolean).join('\n')
207
+
208
+ const prompt = `あなたはプロジェクト「${myProject.name}」のチームメンバー(ロール: ${myProject.role})です。
209
+ 以下のスレッドに対して、あなたが返信すべきかどうかを判断し、返信する場合はその内容を生成してください。
210
+
211
+ スレッドタイプ: ${threadType}
212
+ タイトル: ${threadDetail.title}
213
+ 説明: ${threadDetail.description}
214
+ ${contextInfo}${mentionNote}
215
+
216
+ --- メッセージ履歴 ---
217
+ ${messageHistory || '(メッセージなし)'}
218
+ --- 履歴ここまで ---
219
+
220
+ 以下のJSON形式で回答してください:
221
+ {
222
+ "should_respond": true/false,
223
+ "reason": "判断の理由(1行)",
224
+ "response": "返信内容(should_respondがtrueの場合のみ)"
225
+ }
226
+
227
+ 判断基準:
228
+ - 自分のロール(${myProject.role})に関連する話題か
229
+ - 自分が貢献できる知見や意見があるか
230
+ - 既に十分な回答がある場合は重複を避ける
231
+ - メンションされている場合は必ず返信する
232
+ - 人間に聞くべき場合は @user メンションを含めて返信する`
233
+
234
+ try {
235
+ const result = await llmCallFn(prompt)
236
+
237
+ let parsed
238
+ try {
239
+ // LLM may return JSON wrapped in markdown code block
240
+ const cleaned = result.replace(/```json?\n?/g, '').replace(/```/g, '').trim()
241
+ parsed = JSON.parse(cleaned)
242
+ } catch {
243
+ console.log(`[ThreadWatcher] LLM returned non-JSON for thread ${threadSummary.id}, skipping`)
244
+ return
245
+ }
246
+
247
+ if (parsed.should_respond && parsed.response) {
248
+ // Check if the response contains @user mention
249
+ const mentions = []
250
+ if (parsed.response.includes('@user')) {
251
+ mentions.push('user')
252
+ }
253
+
254
+ await api.request(`/help-threads/${threadSummary.id}/messages`, {
255
+ method: 'POST',
256
+ body: JSON.stringify({
257
+ content: parsed.response,
258
+ mentions,
259
+ }),
260
+ })
261
+ console.log(`[ThreadWatcher] Responded to thread "${threadDetail.title}" (reason: ${parsed.reason})`)
262
+ } else {
263
+ console.log(`[ThreadWatcher] Skipped thread "${threadDetail.title}" (reason: ${parsed.reason || 'not relevant'})`)
264
+ }
265
+ } catch (err) {
266
+ console.error(`[ThreadWatcher] LLM evaluation failed for thread ${threadSummary.id}: ${err.message}`)
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Fallback: memory-only matching when LLM is not available.
272
+ */
273
+ async function fallbackMemoryMatch(thread) {
274
+ try {
275
+ const ctx = thread.context || {}
276
+ const category = ctx.category || ''
277
+ const searchParam = category ? `&category=${category}` : ''
278
+ const memData = await api.request(
279
+ `/project-memories?project_id=${thread.project_id}${searchParam}`
280
+ )
281
+
282
+ if (!memData.memories || memData.memories.length === 0) return
283
+
284
+ const keywords = (thread.title + ' ' + thread.description)
285
+ .toLowerCase()
286
+ .split(/[\s,.\-_/]+/)
287
+ .filter(w => w.length >= 3)
288
+ .filter((w, i, arr) => arr.indexOf(w) === i)
289
+ .slice(0, 10)
290
+
291
+ const relevant = memData.memories.filter(m =>
292
+ keywords.some(kw =>
293
+ m.title.toLowerCase().includes(kw) ||
294
+ m.content.toLowerCase().includes(kw)
295
+ )
296
+ )
297
+
298
+ if (relevant.length === 0) return
299
+
300
+ const knowledgeSummary = relevant
301
+ .map(m => `[${m.title}] ${m.content}`)
302
+ .join('\n\n')
303
+
304
+ await api.request(`/help-threads/${thread.id}/messages`, {
305
+ method: 'POST',
306
+ body: JSON.stringify({
307
+ content: `関連するプロジェクトメモリーが見つかりました:\n\n${knowledgeSummary}`,
308
+ }),
309
+ })
310
+
311
+ console.log(`[ThreadWatcher] Posted ${relevant.length} relevant memories for thread ${thread.id}`)
312
+ } catch (err) {
313
+ console.error(`[ThreadWatcher] Memory check failed for thread ${thread.id}: ${err.message}`)
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Start the thread watcher daemon.
319
+ * @param {Function} runQuickLlmCall - LLM function for evaluating threads
320
+ */
321
+ function start(runQuickLlmCall) {
322
+ llmCallFn = runQuickLlmCall || null
323
+
324
+ if (!isHqConfigured()) {
325
+ console.log('[ThreadWatcher] HQ not configured, thread watcher disabled')
326
+ return
327
+ }
328
+
329
+ if (!llmCallFn) {
330
+ console.log('[ThreadWatcher] LLM not available, using memory-only matching')
331
+ }
332
+
333
+ // Initial poll after a short delay
334
+ setTimeout(() => pollOnce(), 8000)
335
+
336
+ // Periodic polling
337
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
338
+ console.log(`[ThreadWatcher] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
339
+ }
340
+
341
+ /**
342
+ * Stop the thread watcher daemon.
343
+ */
344
+ function stop() {
345
+ if (pollTimer) {
346
+ clearInterval(pollTimer)
347
+ pollTimer = null
348
+ console.log('[ThreadWatcher] Stopped')
349
+ }
350
+ }
351
+
352
+ function getStatus() {
353
+ return {
354
+ running: pollTimer !== null,
355
+ last_poll_at: lastPollAt,
356
+ }
357
+ }
358
+
359
+ module.exports = { start, stop, pollOnce, getStatus }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Daemon status route
3
+ *
4
+ * GET /api/daemons/status - Returns the status of all background daemons
5
+ *
6
+ * No auth required (local CLI access, same as /api/health and /api/status).
7
+ * Requires opts.heartbeatStatus (injected from server.js) for heartbeat state.
8
+ */
9
+
10
+ const stepPoller = require('../lib/step-poller')
11
+ const revisionWatcher = require('../lib/revision-watcher')
12
+ const threadWatcher = require('../lib/thread-watcher')
13
+ const reflectionScheduler = require('../lib/reflection-scheduler')
14
+
15
+ /**
16
+ * @param {import('fastify').FastifyInstance} fastify
17
+ * @param {{ heartbeatStatus: () => object }} opts
18
+ */
19
+ async function daemonRoutes(fastify, opts) {
20
+ fastify.get('/api/daemons/status', async () => {
21
+ return {
22
+ success: true,
23
+ daemons: {
24
+ step_poller: stepPoller.getStatus(),
25
+ revision_watcher: revisionWatcher.getStatus(),
26
+ thread_watcher: threadWatcher.getStatus(),
27
+ reflection_scheduler: reflectionScheduler.getStatus(),
28
+ heartbeat: opts.heartbeatStatus ? opts.heartbeatStatus() : { running: false },
29
+ },
30
+ }
31
+ })
32
+ }
33
+
34
+ module.exports = { daemonRoutes }