@geekbeer/minion 2.62.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 }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Project thread routes (local API on minion)
3
+ *
4
+ * Endpoints:
5
+ * - GET /api/help-threads - List open threads in minion's projects
6
+ * - POST /api/help-threads - Create a new thread (proxied to HQ)
7
+ * - GET /api/help-threads/:id - Get thread detail with messages
8
+ * - POST /api/help-threads/:id/messages - Post a message to a thread
9
+ * - POST /api/help-threads/:id/resolve - Resolve a thread
10
+ * - POST /api/help-threads/:id/cancel - Cancel a thread
11
+ * - DELETE /api/help-threads/:id - Permanently delete a thread (PM only)
12
+ * - GET /api/project-memories - Search project memories
13
+ * - POST /api/project-memories - Create a project memory
14
+ */
15
+
16
+ const { verifyToken } = require('../lib/auth')
17
+ const api = require('../api')
18
+
19
+ /**
20
+ * @param {import('fastify').FastifyInstance} fastify
21
+ */
22
+ async function helpThreadRoutes(fastify) {
23
+ // List open threads
24
+ fastify.get('/api/help-threads', async (request, reply) => {
25
+ if (!verifyToken(request)) {
26
+ reply.code(401)
27
+ return { success: false, error: 'Unauthorized' }
28
+ }
29
+
30
+ try {
31
+ const data = await api.getOpenHelpThreads()
32
+ return { success: true, threads: data.threads || [] }
33
+ } catch (error) {
34
+ console.error(`[HelpThreads] List error: ${error.message}`)
35
+ reply.code(500)
36
+ return { success: false, error: error.message }
37
+ }
38
+ })
39
+
40
+ // Create thread
41
+ fastify.post('/api/help-threads', async (request, reply) => {
42
+ if (!verifyToken(request)) {
43
+ reply.code(401)
44
+ return { success: false, error: 'Unauthorized' }
45
+ }
46
+
47
+ try {
48
+ const data = await api.createHelpThread(request.body)
49
+ return { success: true, thread: data.thread }
50
+ } catch (error) {
51
+ console.error(`[HelpThreads] Create error: ${error.message}`)
52
+ reply.code(error.statusCode || 500)
53
+ return { success: false, error: error.message }
54
+ }
55
+ })
56
+
57
+ // Get thread detail
58
+ fastify.get('/api/help-threads/:id', async (request, reply) => {
59
+ if (!verifyToken(request)) {
60
+ reply.code(401)
61
+ return { success: false, error: 'Unauthorized' }
62
+ }
63
+
64
+ try {
65
+ const data = await api.getHelpThread(request.params.id)
66
+ return { success: true, thread: data.thread, messages: data.messages }
67
+ } catch (error) {
68
+ console.error(`[HelpThreads] Get error: ${error.message}`)
69
+ reply.code(error.statusCode || 500)
70
+ return { success: false, error: error.message }
71
+ }
72
+ })
73
+
74
+ // Post message
75
+ fastify.post('/api/help-threads/:id/messages', async (request, reply) => {
76
+ if (!verifyToken(request)) {
77
+ reply.code(401)
78
+ return { success: false, error: 'Unauthorized' }
79
+ }
80
+
81
+ try {
82
+ const data = await api.postHelpMessage(request.params.id, request.body)
83
+ return { success: true, message: data.message }
84
+ } catch (error) {
85
+ console.error(`[HelpThreads] Message error: ${error.message}`)
86
+ reply.code(error.statusCode || 500)
87
+ return { success: false, error: error.message }
88
+ }
89
+ })
90
+
91
+ // Resolve thread
92
+ fastify.post('/api/help-threads/:id/resolve', async (request, reply) => {
93
+ if (!verifyToken(request)) {
94
+ reply.code(401)
95
+ return { success: false, error: 'Unauthorized' }
96
+ }
97
+
98
+ try {
99
+ const { resolution } = request.body || {}
100
+ const data = await api.resolveHelpThread(request.params.id, resolution)
101
+ return { success: true, thread: data.thread }
102
+ } catch (error) {
103
+ console.error(`[HelpThreads] Resolve error: ${error.message}`)
104
+ reply.code(error.statusCode || 500)
105
+ return { success: false, error: error.message }
106
+ }
107
+ })
108
+
109
+ // Permanently delete thread (PM only)
110
+ fastify.delete('/api/help-threads/:id', async (request, reply) => {
111
+ if (!verifyToken(request)) {
112
+ reply.code(401)
113
+ return { success: false, error: 'Unauthorized' }
114
+ }
115
+
116
+ try {
117
+ const data = await api.deleteHelpThread(request.params.id)
118
+ return { success: true, deleted: data.deleted }
119
+ } catch (error) {
120
+ console.error(`[HelpThreads] Delete error: ${error.message}`)
121
+ reply.code(error.statusCode || 500)
122
+ return { success: false, error: error.message }
123
+ }
124
+ })
125
+
126
+ // Cancel thread
127
+ fastify.post('/api/help-threads/:id/cancel', async (request, reply) => {
128
+ if (!verifyToken(request)) {
129
+ reply.code(401)
130
+ return { success: false, error: 'Unauthorized' }
131
+ }
132
+
133
+ try {
134
+ const { reason } = request.body || {}
135
+ const data = await api.cancelHelpThread(request.params.id, reason)
136
+ return { success: true, thread: data.thread }
137
+ } catch (error) {
138
+ console.error(`[HelpThreads] Cancel error: ${error.message}`)
139
+ reply.code(error.statusCode || 500)
140
+ return { success: false, error: error.message }
141
+ }
142
+ })
143
+
144
+ // Search project memories
145
+ fastify.get('/api/project-memories', async (request, reply) => {
146
+ if (!verifyToken(request)) {
147
+ reply.code(401)
148
+ return { success: false, error: 'Unauthorized' }
149
+ }
150
+
151
+ try {
152
+ const { project_id, search, category, tags, status } = request.query || {}
153
+ if (!project_id) {
154
+ reply.code(400)
155
+ return { success: false, error: 'project_id is required' }
156
+ }
157
+ const data = await api.searchProjectMemories(project_id, {
158
+ search,
159
+ category,
160
+ tags: tags ? tags.split(',') : undefined,
161
+ status,
162
+ })
163
+ return { success: true, memories: data.memories || [] }
164
+ } catch (error) {
165
+ console.error(`[ProjectMemories] Search error: ${error.message}`)
166
+ reply.code(error.statusCode || 500)
167
+ return { success: false, error: error.message }
168
+ }
169
+ })
170
+
171
+ // Create project memory
172
+ fastify.post('/api/project-memories', async (request, reply) => {
173
+ if (!verifyToken(request)) {
174
+ reply.code(401)
175
+ return { success: false, error: 'Unauthorized' }
176
+ }
177
+
178
+ try {
179
+ const data = await api.createProjectMemory(request.body)
180
+ return { success: true, memory: data.memory }
181
+ } catch (error) {
182
+ console.error(`[ProjectMemories] Create error: ${error.message}`)
183
+ reply.code(error.statusCode || 500)
184
+ return { success: false, error: error.message }
185
+ }
186
+ })
187
+ }
188
+
189
+ module.exports = { helpThreadRoutes }
@@ -244,6 +244,174 @@ Supported `cli_type`: `claude-code`, `gemini`, `codex`
244
244
 
245
245
  Note: Claude Code の場合、書き込み先は `settings.local.json`(サーバー再起動時に上書きされない)。
246
246
 
247
+ ### Daemon Status (デーモン監視)
248
+
249
+ バックグラウンドデーモンの稼働状態を確認する。
250
+
251
+ | Method | Endpoint | Description |
252
+ |--------|----------|-------------|
253
+ | GET | `/api/daemons/status` | 全デーモンのステータス一覧 |
254
+
255
+ GET `/api/daemons/status` response:
256
+ ```json
257
+ {
258
+ "success": true,
259
+ "daemons": {
260
+ "step_poller": { "running": true, "last_poll_at": "2026-03-19T15:30:00.000Z" },
261
+ "revision_watcher": { "running": true, "last_poll_at": "2026-03-19T15:30:05.000Z" },
262
+ "thread_watcher": { "running": true, "last_poll_at": "2026-03-19T15:29:52.000Z" },
263
+ "reflection_scheduler": { "running": true, "next_run": "2026-03-20T03:00:00.000Z" },
264
+ "heartbeat": { "running": true, "last_beat_at": "2026-03-19T15:30:10.000Z" }
265
+ }
266
+ }
267
+ ```
268
+
269
+ | Daemon | 概要 |
270
+ |--------|------|
271
+ | `step_poller` | ワークフローステップの取得・実行(30秒間隔) |
272
+ | `revision_watcher` | リビジョン要求の検知(30秒間隔、PMのみ) |
273
+ | `thread_watcher` | プロジェクトスレッドの監視・LLM評価(15秒間隔) |
274
+ | `reflection_scheduler` | 1日1回の振り返り(cron) |
275
+ | `heartbeat` | HQへのハートビート(30秒間隔) |
276
+
277
+ ### Project Threads (プロジェクトスレッド)
278
+
279
+ プロジェクト内のコミュニケーションチャネル。ブロッカー共有やチーム議論に使う。
280
+ リクエストは HQ にプロキシされる。
281
+
282
+ | Method | Endpoint | Description |
283
+ |--------|----------|-------------|
284
+ | GET | `/api/help-threads` | 参加プロジェクトのオープンスレッド一覧 |
285
+ | POST | `/api/help-threads` | スレッドを起票 |
286
+ | GET | `/api/help-threads/:id` | スレッド詳細 + メッセージ一覧 |
287
+ | POST | `/api/help-threads/:id/messages` | スレッドにメッセージを投稿 |
288
+ | POST | `/api/help-threads/:id/resolve` | スレッドを解決済みにする |
289
+ | POST | `/api/help-threads/:id/cancel` | スレッドをキャンセル |
290
+ | DELETE | `/api/help-threads/:id` | スレッドを完全削除(PMのみ) |
291
+
292
+ POST `/api/help-threads` body (ヘルプスレッド起票):
293
+ ```json
294
+ {
295
+ "project_id": "uuid",
296
+ "thread_type": "help",
297
+ "title": "ランサーズの2FA認証コードが必要",
298
+ "description": "ログイン時に2段階認証を要求された。メールで届く6桁コードの入力が必要。",
299
+ "mentions": ["role:pm"],
300
+ "context": {
301
+ "category": "auth",
302
+ "attempted_resolution": "保存済み認証情報で再ログイン試行済み、2FA必須"
303
+ }
304
+ }
305
+ ```
306
+
307
+ POST `/api/help-threads` body (ディスカッションスレッド起票):
308
+ ```json
309
+ {
310
+ "project_id": "uuid",
311
+ "thread_type": "discussion",
312
+ "title": "デプロイ手順の確認",
313
+ "description": "本番デプロイ前にステージングで確認するフローに変えたい。意見ある?",
314
+ "mentions": ["role:engineer"]
315
+ }
316
+ ```
317
+
318
+ | Field | Type | Required | Description |
319
+ |-------|------|----------|-------------|
320
+ | `project_id` | string | Yes | プロジェクト UUID |
321
+ | `thread_type` | string | No | `help`(デフォルト)or `discussion` |
322
+ | `title` | string | Yes | スレッドの要約 |
323
+ | `description` | string | Yes | 詳細説明 |
324
+ | `mentions` | string[] | No | メンション対象。形式: `role:engineer`, `role:pm`, `minion:<minion_id>`, `user` |
325
+ | `context` | object | No | 任意のメタデータ(category, urgency, workflow_execution_id等) |
326
+
327
+ **thread_type の違い:**
328
+ - `help`: ブロッカー解決。`resolve` で解決
329
+ - `discussion`: チーム内ディスカッション。`close` で完了
330
+
331
+ **メンションルール:**
332
+ - メンションされたミニオンは優先的にスレッドを評価する
333
+ - メンションがない場合、全チームメンバーがLLMで関連性を判定してから参加
334
+ - トークン消費を抑えるため、当事者が明確な場合はメンションを推奨
335
+ - ミニオンが自力で解決できない場合、`@user` メンション付きで返信して人間に助けを求める
336
+
337
+ POST `/api/help-threads/:id/messages` body:
338
+ ```json
339
+ {
340
+ "content": "関連するプロジェクトメモリーが見つかりました: ...",
341
+ "mentions": ["user"]
342
+ }
343
+ ```
344
+
345
+ | Field | Type | Required | Description |
346
+ |-------|------|----------|-------------|
347
+ | `content` | string | Yes | メッセージ本文 |
348
+ | `attachments` | object | No | 添付ファイル情報(JSON) |
349
+ | `mentions` | string[] | No | メンション対象 |
350
+
351
+ POST `/api/help-threads/:id/resolve` body:
352
+ ```json
353
+ {
354
+ "resolution": "ユーザーから2FAコード(123456)を受け取りログイン成功"
355
+ }
356
+ ```
357
+
358
+ POST `/api/help-threads/:id/cancel` body (optional):
359
+ ```json
360
+ {
361
+ "reason": "誤って起票したため取り消し"
362
+ }
363
+ ```
364
+
365
+ スレッドのステータスは Supabase Realtime で配信されるため、ポーリングに加えて
366
+ HQ ダッシュボードではリアルタイムに更新が表示される。
367
+
368
+ ### Project Memories (プロジェクト共有知識)
369
+
370
+ プロジェクトレベルのチーム共有知識ベース。ヘルプスレッドの解決知見を蓄積して再利用する。
371
+ リクエストは HQ にプロキシされる。
372
+
373
+ | Method | Endpoint | Description |
374
+ |--------|----------|-------------|
375
+ | GET | `/api/project-memories` | メモリー検索。Query: `?project_id=&search=&category=&tags=&status=` |
376
+ | POST | `/api/project-memories` | メモリーを投稿 |
377
+
378
+ GET Query Parameters:
379
+
380
+ | Param | Required | Description |
381
+ |-------|----------|-------------|
382
+ | `project_id` | Yes | プロジェクト UUID |
383
+ | `search` | No | タイトル・内容でのテキスト検索 |
384
+ | `category` | No | カテゴリフィルター |
385
+ | `tags` | No | タグフィルター(カンマ区切り) |
386
+ | `status` | No | `active`(デフォルト), `outdated`, `archived` |
387
+
388
+ POST `/api/project-memories` body:
389
+ ```json
390
+ {
391
+ "project_id": "uuid",
392
+ "title": "ランサーズは2FA必須",
393
+ "content": "ランサーズはログイン時に2段階認証を要求する。メールで届くコードが必要なため、人間へのエスカレーションが必須。team_timeout=30sが適切。",
394
+ "category": "auth",
395
+ "tags": ["lancers", "2fa", "login"],
396
+ "source_thread_id": "uuid"
397
+ }
398
+ ```
399
+
400
+ | Field | Type | Required | Description |
401
+ |-------|------|----------|-------------|
402
+ | `project_id` | string | Yes | プロジェクト UUID |
403
+ | `title` | string | Yes | 知見のタイトル |
404
+ | `content` | string | Yes | 知見の内容 |
405
+ | `category` | string | Yes | `auth`, `environment`, `know-how`, `decision`, `preference` |
406
+ | `tags` | string[] | No | 自由タグ(検索用) |
407
+ | `source_thread_id` | string | No | 知見の出典ヘルプスレッド UUID |
408
+
409
+ **推奨ワークフロー:**
410
+ 1. ブロッカー発生 → `GET /api/project-memories?project_id=...&category=auth&search=2fa` で既知の知見を検索
411
+ 2. 該当あり → 知見に基づいて自己解決 or 即エスカレーション
412
+ 3. 該当なし → `POST /api/help-threads` でブロッカー起票
413
+ 4. 解決後 → `POST /api/project-memories` で知見を蓄積
414
+
247
415
  ### Commands
248
416
 
249
417
  | Method | Endpoint | Description |
@@ -601,3 +769,30 @@ Response:
601
769
  | POST | `/api/minion/skills` | スキル登録/更新(新バージョン自動作成) |
602
770
 
603
771
  スキルはバージョン管理される。push ごとに新バージョンが作成され、ファイルは Supabase Storage に保存される。
772
+
773
+ ### Help Threads (HQ)
774
+
775
+ | Method | Endpoint | Description |
776
+ |--------|----------|-------------|
777
+ | POST | `/api/minion/help-threads` | ブロッカースレッドを起票 |
778
+ | GET | `/api/minion/help-threads/open` | 参加プロジェクトの未解決スレッド一覧 |
779
+ | GET | `/api/minion/help-threads/:id` | スレッド詳細 + メッセージ一覧 |
780
+ | POST | `/api/minion/help-threads/:id/messages` | スレッドにメッセージを投稿 |
781
+ | PATCH | `/api/minion/help-threads/:id/resolve` | スレッドを解決済みにする。Body: `{resolution}` |
782
+ | PATCH | `/api/minion/help-threads/:id/cancel` | スレッドをキャンセル。Body: `{reason?}` |
783
+ | DELETE | `/api/minion/help-threads/:id` | スレッドを完全削除(PMのみ)。メッセージもCASCADE削除 |
784
+ | PATCH | `/api/minion/help-threads/:id/escalate` | スレッドを手動エスカレーション |
785
+
786
+ ローカルエージェントの `/api/help-threads` は上記 HQ API へのプロキシ。
787
+ 詳細なリクエスト/レスポンス仕様はローカル API セクションの「Help Threads」を参照。
788
+
789
+ ### Project Memories (HQ)
790
+
791
+ | Method | Endpoint | Description |
792
+ |--------|----------|-------------|
793
+ | POST | `/api/minion/project-memories` | プロジェクトメモリーを投稿 |
794
+ | GET | `/api/minion/project-memories` | メモリー検索。Query: `?project_id=&search=&category=&tags=&status=` |
795
+ | PATCH | `/api/minion/project-memories/:id` | メモリーを更新。Body: `{title?, content?, category?, tags?, status?}` |
796
+
797
+ ローカルエージェントの `/api/project-memories` は上記 HQ API へのプロキシ。
798
+ 詳細なリクエスト/レスポンス仕様はローカル API セクションの「Project Memories」を参照。
@@ -12,6 +12,7 @@
12
12
  # sudo minion-cli restart # Restart agent service (root)
13
13
  # minion-cli status # Get current status
14
14
  # minion-cli health # Health check
15
+ # minion-cli daemons # Daemon status
15
16
  # minion-cli diagnose # Run full service diagnostics
16
17
  # minion-cli set-status busy "Running X" # Set status and task
17
18
  # minion-cli set-status online # Set status only
@@ -814,6 +815,7 @@ CFEOF
814
815
  echo "Useful commands:"
815
816
  echo " minion-cli status # Agent status"
816
817
  echo " minion-cli health # Health check"
818
+ echo " minion-cli daemons # Daemon status"
817
819
  echo " sudo minion-cli restart # Restart agent"
818
820
  echo " sudo minion-cli stop # Stop agent"
819
821
  if [ "$PROC_MGR" = "systemd" ]; then
@@ -1219,6 +1221,10 @@ case "${1:-}" in
1219
1221
  curl -s "$AGENT_URL/api/health" | jq .
1220
1222
  ;;
1221
1223
 
1224
+ daemons)
1225
+ curl -s "$AGENT_URL/api/daemons/status" | jq .
1226
+ ;;
1227
+
1222
1228
  diagnose)
1223
1229
  echo "Running diagnostics..."
1224
1230
  echo ""
package/linux/server.js CHANGED
@@ -43,6 +43,7 @@ const routineStore = require('../core/stores/routine-store')
43
43
  // Heartbeat interval: fixed at 30s (not user-configurable)
44
44
  const HEARTBEAT_INTERVAL_MS = 30_000
45
45
  let heartbeatTimer = null
46
+ let lastBeatAt = null
46
47
 
47
48
  // Linux-specific modules
48
49
  const workflowRunner = require('./workflow-runner')
@@ -50,10 +51,14 @@ const routineRunner = require('./routine-runner')
50
51
  const { cleanupTtyd, killStaleTtydProcesses } = require('./routes/terminal')
51
52
  const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
52
53
 
54
+ // Config warnings (included in heartbeat)
55
+ const { getConfigWarnings } = require('../core/lib/config-warnings')
56
+
53
57
  // Pull-model daemons (from core/)
54
58
  const stepPoller = require('../core/lib/step-poller')
55
59
  const revisionWatcher = require('../core/lib/revision-watcher')
56
60
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
61
+ const threadWatcher = require('../core/lib/thread-watcher')
57
62
  const runningTasks = require('../core/lib/running-tasks')
58
63
 
59
64
  // Shared routes (from core/)
@@ -68,6 +73,8 @@ const { memoryRoutes } = require('../core/routes/memory')
68
73
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
69
74
  const { sudoersRoutes } = require('../core/routes/sudoers')
70
75
  const { permissionRoutes } = require('../core/routes/permissions')
76
+ const { helpThreadRoutes } = require('../core/routes/help-threads')
77
+ const { daemonRoutes } = require('../core/routes/daemons')
71
78
 
72
79
  // Linux-specific routes
73
80
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
@@ -113,6 +120,7 @@ async function shutdown(signal) {
113
120
  stepPoller.stop()
114
121
  revisionWatcher.stop()
115
122
  reflectionScheduler.stop()
123
+ threadWatcher.stop()
116
124
  workflowRunner.stopAll()
117
125
  routineRunner.stopAll()
118
126
 
@@ -270,6 +278,8 @@ async function registerAllRoutes(app) {
270
278
  await app.register(dailyLogRoutes)
271
279
  await app.register(sudoersRoutes)
272
280
  await app.register(permissionRoutes)
281
+ await app.register(helpThreadRoutes)
282
+ await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
273
283
 
274
284
  // Linux-specific routes
275
285
  await app.register(commandRoutes)
@@ -353,14 +363,16 @@ async function start() {
353
363
  // Send initial online heartbeat
354
364
  const { getStatus } = require('../core/routes/health')
355
365
  const { currentTask } = getStatus()
356
- sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), version }).catch(err => {
366
+ sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), version }).catch(err => {
357
367
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
358
368
  })
359
369
 
360
370
  // Start periodic heartbeat
361
371
  heartbeatTimer = setInterval(() => {
362
372
  const { currentStatus, currentTask } = getStatus()
363
- sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), version }).catch(err => {
373
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), config_warnings: getConfigWarnings(), version }).then(() => {
374
+ lastBeatAt = new Date().toISOString()
375
+ }).catch(err => {
364
376
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
365
377
  })
366
378
  }, HEARTBEAT_INTERVAL_MS)
@@ -369,6 +381,7 @@ async function start() {
369
381
  // Start Pull-model daemons
370
382
  stepPoller.start()
371
383
  revisionWatcher.start()
384
+ threadWatcher.start(runQuickLlmCall)
372
385
  } else {
373
386
  console.log('[Server] Running in standalone mode (no HQ connection)')
374
387
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.62.0",
3
+ "version": "2.67.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
  # minion-cli-win setup --hq-url https://... --minion-id <UUID> --api-token <TOKEN>
5
5
  # minion-cli-win setup --setup-tunnel
6
6
  # minion-cli-win uninstall [--keep-data]
7
- # minion-cli-win start | stop | restart | status | health | diagnose | version | help
7
+ # minion-cli-win start | stop | restart | status | health | daemons | diagnose | version | help
8
8
 
9
9
  # Parse arguments manually to avoid issues with npm wrapper passing $args as array
10
10
  $Command = 'help'
@@ -18,7 +18,7 @@ $i = 0
18
18
  while ($i -lt $args.Count) {
19
19
  $arg = [string]$args[$i]
20
20
  switch -Regex ($arg) {
21
- '^(setup|reconfigure|uninstall|start|stop|restart|status|health|diagnose|version|help)$' { $Command = $arg }
21
+ '^(setup|reconfigure|uninstall|start|stop|restart|status|health|daemons|diagnose|version|help)$' { $Command = $arg }
22
22
  '^(-v|--version)$' { $Command = 'version' }
23
23
  '^--hq-url$' { $i++; if ($i -lt $args.Count) { $HqUrl = [string]$args[$i] } }
24
24
  '^--minion-id$' { $i++; if ($i -lt $args.Count) { $MinionId = [string]$args[$i] } }
@@ -895,6 +895,7 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
895
895
  Write-Host "Useful commands:"
896
896
  Write-Host " minion-cli-win status # Agent status"
897
897
  Write-Host " minion-cli-win health # Health check"
898
+ Write-Host " minion-cli-win daemons # Daemon status"
898
899
  Write-Host " minion-cli-win restart # Restart agent"
899
900
  Write-Host " minion-cli-win stop # Stop agent"
900
901
  Write-Host " Get-Content $(Join-Path $LogDir 'service-stdout.log') -Tail 50 # View logs"
@@ -1164,6 +1165,15 @@ switch ($Command) {
1164
1165
  Write-Error "Health check failed. Is the agent running?"
1165
1166
  }
1166
1167
  }
1168
+ 'daemons' {
1169
+ try {
1170
+ $response = Invoke-RestMethod -Uri "$AgentUrl/api/daemons/status" -TimeoutSec 5
1171
+ $response | ConvertTo-Json -Depth 5
1172
+ }
1173
+ catch {
1174
+ Write-Error "Failed to get daemon status. Is the agent running?"
1175
+ }
1176
+ }
1167
1177
  'diagnose' {
1168
1178
  Write-Host "Running diagnostics..."
1169
1179
  Write-Host ""
package/win/server.js CHANGED
@@ -23,15 +23,20 @@ const routineStore = require('../core/stores/routine-store')
23
23
  // Heartbeat interval: fixed at 30s (not user-configurable)
24
24
  const HEARTBEAT_INTERVAL_MS = 30_000
25
25
  let heartbeatTimer = null
26
+ let lastBeatAt = null
26
27
 
27
28
  // Windows-specific modules
28
29
  const workflowRunner = require('./workflow-runner')
29
30
  const routineRunner = require('./routine-runner')
30
31
 
32
+ // Config warnings (included in heartbeat)
33
+ const { getConfigWarnings } = require('../core/lib/config-warnings')
34
+
31
35
  // Pull-model daemons (from core/)
32
36
  const stepPoller = require('../core/lib/step-poller')
33
37
  const revisionWatcher = require('../core/lib/revision-watcher')
34
38
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
39
+ const threadWatcher = require('../core/lib/thread-watcher')
35
40
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
36
41
  const { terminalRoutes, cleanupSessions } = require('./routes/terminal')
37
42
  const { startTerminalServer, stopTerminalServer } = require('./terminal-server')
@@ -51,6 +56,8 @@ const { variableRoutes } = require('../core/routes/variables')
51
56
  const { memoryRoutes } = require('../core/routes/memory')
52
57
  const { dailyLogRoutes } = require('../core/routes/daily-logs')
53
58
  const { permissionRoutes } = require('../core/routes/permissions')
59
+ const { helpThreadRoutes } = require('../core/routes/help-threads')
60
+ const { daemonRoutes } = require('../core/routes/daemons')
54
61
 
55
62
  // Validate configuration
56
63
  validate()
@@ -86,6 +93,7 @@ async function shutdown(signal) {
86
93
  stepPoller.stop()
87
94
  revisionWatcher.stop()
88
95
  reflectionScheduler.stop()
96
+ threadWatcher.stop()
89
97
  workflowRunner.stopAll()
90
98
  routineRunner.stopAll()
91
99
  stopTerminalServer()
@@ -203,6 +211,8 @@ async function registerRoutes(app) {
203
211
  await app.register(memoryRoutes)
204
212
  await app.register(dailyLogRoutes)
205
213
  await app.register(permissionRoutes)
214
+ await app.register(helpThreadRoutes)
215
+ await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
206
216
 
207
217
  // Windows-specific routes
208
218
  await app.register(commandRoutes)
@@ -284,14 +294,16 @@ async function start() {
284
294
  // Send initial online heartbeat
285
295
  const { getStatus } = require('../core/routes/health')
286
296
  const { currentTask } = getStatus()
287
- sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
297
+ sendHeartbeat({ status: 'online', current_task: currentTask, config_warnings: getConfigWarnings(), version }).catch(err => {
288
298
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
289
299
  })
290
300
 
291
301
  // Start periodic heartbeat
292
302
  heartbeatTimer = setInterval(() => {
293
303
  const { currentStatus, currentTask } = getStatus()
294
- sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
304
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, config_warnings: getConfigWarnings(), version }).then(() => {
305
+ lastBeatAt = new Date().toISOString()
306
+ }).catch(err => {
295
307
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
296
308
  })
297
309
  }, HEARTBEAT_INTERVAL_MS)
@@ -300,6 +312,7 @@ async function start() {
300
312
  // Start Pull-model daemons
301
313
  stepPoller.start()
302
314
  revisionWatcher.start()
315
+ threadWatcher.start(runQuickLlmCall)
303
316
  } else {
304
317
  console.log('[Server] Running in standalone mode (no HQ connection)')
305
318
  }