@geekbeer/minion 2.32.0 → 2.42.3

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.
Files changed (42) hide show
  1. package/.env.example +0 -3
  2. package/README.md +0 -1
  3. package/core/api.js +13 -0
  4. package/core/config.js +50 -5
  5. package/core/lib/llm-checker.js +9 -16
  6. package/core/lib/log-manager.js +7 -3
  7. package/core/lib/platform.js +10 -15
  8. package/core/lib/revision-watcher.js +252 -0
  9. package/core/lib/step-poller.js +222 -0
  10. package/core/lib/strip-ansi.js +18 -0
  11. package/core/lib/workflow-orchestrator.js +382 -0
  12. package/core/routes/diagnose.js +296 -0
  13. package/core/routes/health.js +27 -0
  14. package/core/routes/routines.js +15 -10
  15. package/core/routes/skills.js +4 -1
  16. package/core/routes/workflows.js +49 -2
  17. package/core/stores/chat-store.js +12 -5
  18. package/core/stores/execution-store.js +4 -4
  19. package/core/stores/routine-store.js +7 -7
  20. package/core/stores/workflow-store.js +5 -6
  21. package/linux/lib/process-manager.js +14 -0
  22. package/linux/minion-cli.sh +57 -16
  23. package/linux/routes/chat.js +182 -20
  24. package/linux/routes/config.js +8 -12
  25. package/linux/routine-runner.js +5 -4
  26. package/linux/server.js +53 -1
  27. package/linux/workflow-runner.js +25 -61
  28. package/package.json +1 -1
  29. package/roles/pm.md +11 -12
  30. package/win/lib/process-manager.js +15 -0
  31. package/win/minion-cli.ps1 +79 -17
  32. package/win/routes/chat.js +178 -14
  33. package/win/routes/config.js +7 -3
  34. package/win/routes/directives.js +1 -1
  35. package/win/routes/terminal.js +19 -0
  36. package/win/routine-runner.js +5 -3
  37. package/win/server.js +53 -0
  38. package/win/terminal-server.js +8 -0
  39. package/win/workflow-runner.js +32 -44
  40. package/skills/execution-report/SKILL.md +0 -106
  41. package/win/lib/llm-checker.js +0 -115
  42. package/win/lib/log-manager.js +0 -119
package/.env.example CHANGED
@@ -17,6 +17,3 @@ MINION_ID=
17
17
 
18
18
  # Agent port (optional, default: 8080)
19
19
  AGENT_PORT=8080
20
-
21
- # Heartbeat interval in seconds (optional, default: 30)
22
- HEARTBEAT_INTERVAL=30
package/README.md CHANGED
@@ -58,7 +58,6 @@ await reportIssue({
58
58
  | `API_TOKEN` | Authentication token | - |
59
59
  | `MINION_ID` | Minion UUID | - |
60
60
  | `AGENT_PORT` | Agent API listen port | `8080` |
61
- | `HEARTBEAT_INTERVAL` | Heartbeat interval (ms) | `30000` |
62
61
 
63
62
  ## License
64
63
 
package/core/api.js CHANGED
@@ -74,9 +74,22 @@ async function reportIssue(data) {
74
74
  })
75
75
  }
76
76
 
77
+ /**
78
+ * Send heartbeat to HQ to report current status.
79
+ * Called periodically, on startup, on shutdown, and on status change.
80
+ * @param {object} data - { status: 'online' | 'offline' | 'busy', current_task?: string | null, version?: string }
81
+ */
82
+ async function sendHeartbeat(data) {
83
+ return request('/heartbeat', {
84
+ method: 'POST',
85
+ body: JSON.stringify(data),
86
+ })
87
+ }
88
+
77
89
  module.exports = {
78
90
  request,
79
91
  reportExecution,
80
92
  reportStepComplete,
81
93
  reportIssue,
94
+ sendHeartbeat,
82
95
  }
package/core/config.js CHANGED
@@ -15,17 +15,49 @@
15
15
  */
16
16
 
17
17
  const os = require('os')
18
+ const fs = require('fs')
18
19
  const { execSync } = require('child_process')
19
20
 
21
+ /**
22
+ * Load .env file into process.env (without overwriting existing values).
23
+ * This ensures values written to the .env file at runtime (e.g. LLM_COMMAND
24
+ * set via the config API) are picked up on process restart, even when the
25
+ * process manager (supervisord) does not include them in its environment line.
26
+ */
27
+ function loadEnvFile() {
28
+ const { resolveEnvFilePath } = require('./lib/platform')
29
+ // resolveHomeDir is not available yet, use a lightweight fallback
30
+ const home = process.env.HOME || os.homedir()
31
+ const envPath = resolveEnvFilePath(home)
32
+ try {
33
+ const content = fs.readFileSync(envPath, 'utf-8')
34
+ for (const line of content.split('\n')) {
35
+ const trimmed = line.trim()
36
+ if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue
37
+ const eqIdx = trimmed.indexOf('=')
38
+ const key = trimmed.slice(0, eqIdx).trim()
39
+ const value = trimmed.slice(eqIdx + 1).trim()
40
+ // Do not overwrite values already set by the process manager
41
+ if (!(key in process.env)) {
42
+ process.env[key] = value
43
+ }
44
+ }
45
+ } catch {
46
+ // .env file doesn't exist or can't be read — not an error
47
+ }
48
+ }
49
+
50
+ loadEnvFile()
51
+
20
52
  /**
21
53
  * Resolve the correct home directory for the minion user.
22
- * In supervisord environments, os.homedir() may return /root because
23
- * the HOME env var is not inherited. This function uses MINION_USER
24
- * (set in .env during setup) to look up the correct home via getent passwd.
54
+ * On Linux, supervisord environments may set HOME=/root incorrectly.
55
+ * This function uses MINION_USER + getent passwd to find the correct home.
56
+ * On Windows, os.homedir() is always correct (returns %USERPROFILE%).
25
57
  */
26
58
  function resolveHomeDir() {
27
59
  const minionUser = process.env.MINION_USER
28
- if (minionUser) {
60
+ if (minionUser && process.platform !== 'win32') {
29
61
  try {
30
62
  const entry = execSync(`getent passwd ${minionUser}`, { encoding: 'utf-8' }).trim()
31
63
  const home = entry.split(':')[5]
@@ -82,4 +114,17 @@ function isLlmConfigured() {
82
114
  return !!config.LLM_COMMAND
83
115
  }
84
116
 
85
- module.exports = { config, validate, isHqConfigured, isLlmConfigured }
117
+ /**
118
+ * Update a config value at runtime (e.g. after .env file write).
119
+ * Also syncs to process.env so child processes inherit the change.
120
+ * @param {string} key
121
+ * @param {string} value
122
+ */
123
+ function updateConfig(key, value) {
124
+ if (key in config) {
125
+ config[key] = value
126
+ process.env[key] = value
127
+ }
128
+ }
129
+
130
+ module.exports = { config, validate, isHqConfigured, isLlmConfigured, updateConfig }
@@ -10,26 +10,13 @@ const fs = require('fs')
10
10
  const path = require('path')
11
11
  const { execSync } = require('child_process')
12
12
  const { config } = require('../config')
13
+ const { IS_WINDOWS, buildExtendedPath } = require('./platform')
13
14
 
14
15
  const CACHE_TTL_MS = 60000
15
16
 
16
17
  let cachedResult = null
17
18
  let cachedAt = 0
18
19
 
19
- /**
20
- * Build extended PATH that includes common Claude CLI installation locations
21
- */
22
- function getExtendedPath() {
23
- const additionalPaths = [
24
- path.join(config.HOME_DIR, '.local', 'bin'),
25
- path.join(config.HOME_DIR, 'bin'),
26
- path.join(config.HOME_DIR, '.npm-global', 'bin'),
27
- path.join(config.HOME_DIR, '.claude', 'bin'),
28
- '/usr/local/bin',
29
- ]
30
- return [...additionalPaths, process.env.PATH || '/usr/bin:/bin'].join(':')
31
- }
32
-
33
20
  /**
34
21
  * Check Claude Code authentication.
35
22
  * First checks known credential file locations (fast path),
@@ -57,14 +44,16 @@ function isClaudeAuthenticated() {
57
44
  // Fallback: check via claude CLI command (handles newer credential storage)
58
45
  try {
59
46
  const claudePath = path.join(config.HOME_DIR, '.local', 'bin', 'claude')
60
- const claudeBin = fs.existsSync(claudePath) ? claudePath : 'claude'
47
+ const claudeBin = (!IS_WINDOWS && fs.existsSync(claudePath)) ? claudePath : 'claude'
61
48
  execSync(`${claudeBin} auth whoami`, {
62
49
  encoding: 'utf-8',
63
50
  timeout: 5000,
64
51
  stdio: 'pipe',
65
52
  env: {
53
+ ...process.env,
66
54
  HOME: config.HOME_DIR,
67
- PATH: getExtendedPath(),
55
+ ...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
56
+ PATH: buildExtendedPath(config.HOME_DIR),
68
57
  },
69
58
  })
70
59
  return true
@@ -83,6 +72,10 @@ function isGeminiAuthenticated() {
83
72
  const possiblePaths = [
84
73
  path.join(config.HOME_DIR, '.config', 'gemini'),
85
74
  path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
75
+ // Windows-specific locations
76
+ ...(IS_WINDOWS ? [
77
+ path.join(config.HOME_DIR, 'AppData', 'Roaming', 'gcloud', 'application_default_credentials.json'),
78
+ ] : []),
86
79
  ]
87
80
  for (const p of possiblePaths) {
88
81
  try {
@@ -6,9 +6,11 @@
6
6
 
7
7
  const fs = require('fs').promises
8
8
  const path = require('path')
9
+ const platform = require('./platform')
10
+ const { stripAnsi } = require('./strip-ansi')
9
11
 
10
- // Log storage configuration
11
- const LOG_DIR = '/opt/minion-agent/logs'
12
+ // Log storage configuration (platform-aware via platform.js)
13
+ const LOG_DIR = platform.LOG_DIR
12
14
  const MAX_LOG_FILES = 100
13
15
 
14
16
  /**
@@ -58,7 +60,9 @@ async function readLog(executionId, options = {}) {
58
60
 
59
61
  try {
60
62
  const stats = await fs.stat(logPath)
61
- const content = await fs.readFile(logPath, 'utf-8')
63
+ const rawContent = await fs.readFile(logPath, 'utf-8')
64
+ // Strip ANSI/TTY escape sequences from tmux pipe-pane output
65
+ const content = stripAnsi(rawContent)
62
66
  const allLines = content.split('\n')
63
67
 
64
68
  let resultContent = content
@@ -2,8 +2,8 @@
2
2
  * Cross-platform utility module
3
3
  *
4
4
  * Provides platform-aware paths, separators, and helpers.
5
- * Used by win/ modules to avoid hardcoded Unix paths.
6
- * This file does NOT modify any existing Linux module behavior.
5
+ * Used by core/ modules (stores, log-manager, llm-checker) and win/ modules
6
+ * to provide consistent cross-platform behavior.
7
7
  */
8
8
 
9
9
  const os = require('os')
@@ -16,22 +16,15 @@ const TEMP_DIR = os.tmpdir()
16
16
 
17
17
  /**
18
18
  * Resolve the data directory for minion agent persistent files.
19
- * Windows: %PROGRAMDATA%\minion-agent (or fallback to %USERPROFILE%\.minion-agent)
19
+ * Windows: %USERPROFILE%\.minion (matches minion-cli.ps1 / start-agent.ps1)
20
20
  * Linux: /opt/minion-agent (existing behavior)
21
21
  */
22
22
  function resolveDataDir() {
23
23
  if (IS_WINDOWS) {
24
- const programData = process.env.PROGRAMDATA || process.env.ALLUSERSPROFILE
25
- if (programData) {
26
- const dir = path.join(programData, 'minion-agent')
27
- try {
28
- fs.mkdirSync(dir, { recursive: true })
29
- return dir
30
- } catch {
31
- // Fall through to home-based path
32
- }
33
- }
34
- return path.join(os.homedir(), '.minion-agent')
24
+ // Use ~/.minion to match minion-cli.ps1 and start-agent.ps1.
25
+ // All Windows-specific code (CLI, process-manager, server.js) uses ~/.minion
26
+ // as the canonical data directory, so core modules must align with it.
27
+ return path.join(os.homedir(), '.minion')
35
28
  }
36
29
  return '/opt/minion-agent'
37
30
  }
@@ -89,7 +82,9 @@ function getDefaultShell() {
89
82
 
90
83
  /**
91
84
  * Resolve .env file path.
92
- * Prefers DATA_DIR/.env, falls back to ~/minion.env.
85
+ * Returns DATA_DIR/.env (which is ~/.minion/.env on Windows,
86
+ * /opt/minion-agent/.env on Linux).
87
+ * Falls back to ~/minion.env if DATA_DIR is not writable.
93
88
  * @param {string} homeDir - User home directory
94
89
  * @returns {string} Path to .env file
95
90
  */
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Revision Watcher
3
+ *
4
+ * PM-only polling daemon that detects steps with revision_requested
5
+ * review status and handles revision routing.
6
+ *
7
+ * When a reviewer requests changes on a completed step:
8
+ * 1. This watcher detects the revision_requested status
9
+ * 2. Uses LLM to decide which step to roll back to
10
+ * 3. Calls HQ's /api/minion/revision-reset to reset affected steps
11
+ * 4. The step-poller on the target minion picks up the re-pending step
12
+ *
13
+ * Only runs on minions that have PM role in at least one project.
14
+ */
15
+
16
+ const { config, isHqConfigured } = require('../config')
17
+ const api = require('../api')
18
+
19
+ // Poll every 30 seconds (same frequency as step-poller)
20
+ const POLL_INTERVAL_MS = 30_000
21
+
22
+ let polling = false
23
+ let pollTimer = null
24
+
25
+ // Track revisions being processed to avoid duplicate handling
26
+ const processingRevisions = new Set()
27
+
28
+ /**
29
+ * Poll HQ for pending revisions and handle them.
30
+ */
31
+ async function pollOnce() {
32
+ if (!isHqConfigured()) return
33
+ if (polling) return
34
+
35
+ polling = true
36
+ try {
37
+ const data = await api.request('/pending-revisions')
38
+
39
+ if (!data.revisions || data.revisions.length === 0) {
40
+ return
41
+ }
42
+
43
+ console.log(`[RevisionWatcher] Found ${data.revisions.length} pending revision(s)`)
44
+
45
+ for (const revision of data.revisions) {
46
+ const key = `${revision.execution_id}-${revision.revision_step_index}`
47
+ if (processingRevisions.has(key)) {
48
+ continue
49
+ }
50
+
51
+ processingRevisions.add(key)
52
+ try {
53
+ await handleRevision(revision)
54
+ } finally {
55
+ processingRevisions.delete(key)
56
+ }
57
+ }
58
+ } catch (err) {
59
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
60
+ console.log(`[RevisionWatcher] HQ unreachable, will retry next cycle`)
61
+ } else {
62
+ console.error(`[RevisionWatcher] Poll error: ${err.message}`)
63
+ }
64
+ } finally {
65
+ polling = false
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Handle a single revision request:
71
+ * 1. Decide which step to roll back to (LLM or simple heuristic)
72
+ * 2. Call revision-reset API
73
+ *
74
+ * @param {object} revision
75
+ * @param {string} revision.execution_id
76
+ * @param {string} revision.workflow_name
77
+ * @param {number} revision.revision_step_index
78
+ * @param {string} revision.review_comment
79
+ * @param {Array<{step_index: number, skill_name: string|null, assigned_role: string}>} revision.pipeline
80
+ */
81
+ async function handleRevision(revision) {
82
+ const { execution_id, workflow_name, revision_step_index, review_comment, pipeline } = revision
83
+
84
+ console.log(
85
+ `[RevisionWatcher] Handling revision for "${workflow_name}" step ${revision_step_index}: "${review_comment}"`
86
+ )
87
+
88
+ // Decide target step for rollback
89
+ const targetStepIndex = await decideRevisionTarget(pipeline, review_comment, revision_step_index)
90
+
91
+ console.log(`[RevisionWatcher] Rolling back to step ${targetStepIndex}`)
92
+
93
+ // Call revision-reset API on HQ
94
+ try {
95
+ await api.request('/revision-reset', {
96
+ method: 'POST',
97
+ body: JSON.stringify({
98
+ execution_id,
99
+ target_step_index: targetStepIndex,
100
+ revision_step_index: revision_step_index,
101
+ revision_feedback: review_comment,
102
+ }),
103
+ })
104
+
105
+ console.log(
106
+ `[RevisionWatcher] Revision reset complete: steps ${targetStepIndex}-${revision_step_index} ` +
107
+ `of execution ${execution_id} reset to pending`
108
+ )
109
+ } catch (err) {
110
+ console.error(`[RevisionWatcher] Revision reset failed: ${err.message}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Decide which step to roll back to.
116
+ * Uses LLM when multiple steps are involved, otherwise defaults to the reviewed step.
117
+ */
118
+ async function decideRevisionTarget(pipeline, reviewComment, currentStepIndex) {
119
+ // If only one step or reviewing the first step, no choice needed
120
+ if (currentStepIndex === 0 || pipeline.length <= 1) {
121
+ return currentStepIndex
122
+ }
123
+
124
+ // Build pipeline description for LLM
125
+ const pipelineDesc = pipeline
126
+ .map(s => `Step ${s.step_index}: ${s.skill_name || 'unknown'} (role: ${s.assigned_role})`)
127
+ .join('\n')
128
+
129
+ const systemPrompt = `You are analyzing a workflow pipeline to decide which step to roll back to after a reviewer requested changes.
130
+
131
+ Given the pipeline steps and the reviewer's feedback, determine which step is the root cause that needs to be re-executed.
132
+ - If the feedback targets the current step's output only, return the current step index.
133
+ - If the feedback suggests an earlier step produced incorrect input, return that earlier step's index.
134
+ - Always return the EARLIEST step that needs re-execution.
135
+
136
+ Respond with ONLY a JSON object: {"target_step_index": <number>}
137
+ Do not include any other text.`
138
+
139
+ const userPrompt = `## Pipeline (steps 0 through ${currentStepIndex})
140
+ ${pipelineDesc}
141
+
142
+ ## Reviewer Feedback
143
+ ${reviewComment}
144
+
145
+ ## Current Step (reviewed)
146
+ Step ${currentStepIndex}`
147
+
148
+ // Load optional PM revision policy
149
+ let revisionPolicy = ''
150
+ try {
151
+ const fs = require('fs').promises
152
+ const path = require('path')
153
+ const policyPath = path.join(config.HOME_DIR, '.minion', 'revision-policy.md')
154
+ revisionPolicy = await fs.readFile(policyPath, 'utf-8')
155
+ } catch {
156
+ // No custom policy
157
+ }
158
+
159
+ if (revisionPolicy) {
160
+ // Append policy to user prompt (same as orchestrator did)
161
+ }
162
+
163
+ try {
164
+ const result = await callLlmForJson(systemPrompt, userPrompt)
165
+
166
+ if (
167
+ result &&
168
+ typeof result.target_step_index === 'number' &&
169
+ Number.isInteger(result.target_step_index) &&
170
+ result.target_step_index >= 0 &&
171
+ result.target_step_index <= currentStepIndex
172
+ ) {
173
+ console.log(`[RevisionWatcher] LLM decided revision target: step ${result.target_step_index}`)
174
+ return result.target_step_index
175
+ }
176
+
177
+ console.warn(`[RevisionWatcher] LLM returned invalid target, falling back to step ${currentStepIndex}`)
178
+ return currentStepIndex
179
+ } catch (err) {
180
+ console.error(`[RevisionWatcher] LLM call failed, falling back to step ${currentStepIndex}: ${err.message}`)
181
+ return currentStepIndex
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Call LLM API for JSON response.
187
+ * Reuses the same Anthropic Messages API pattern as workflow-orchestrator.
188
+ */
189
+ async function callLlmForJson(systemPrompt, userPrompt) {
190
+ const apiKey = process.env.ANTHROPIC_API_KEY
191
+ if (!apiKey) {
192
+ throw new Error('ANTHROPIC_API_KEY not set — cannot make LLM call for revision routing')
193
+ }
194
+
195
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
196
+ method: 'POST',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ 'x-api-key': apiKey,
200
+ 'anthropic-version': '2023-06-01',
201
+ },
202
+ body: JSON.stringify({
203
+ model: 'claude-haiku-4-5-20251001',
204
+ max_tokens: 256,
205
+ system: systemPrompt,
206
+ messages: [{ role: 'user', content: userPrompt }],
207
+ }),
208
+ })
209
+
210
+ if (!resp.ok) {
211
+ const text = await resp.text()
212
+ throw new Error(`Anthropic API error: ${resp.status} ${text}`)
213
+ }
214
+
215
+ const data = await resp.json()
216
+ const content = data.content?.[0]?.text
217
+ if (!content) {
218
+ throw new Error('Empty response from Anthropic API')
219
+ }
220
+
221
+ return JSON.parse(content)
222
+ }
223
+
224
+ /**
225
+ * Start the revision watcher daemon.
226
+ */
227
+ function start() {
228
+ if (!isHqConfigured()) {
229
+ console.log('[RevisionWatcher] HQ not configured, revision watcher disabled')
230
+ return
231
+ }
232
+
233
+ // Initial poll after a short delay
234
+ setTimeout(() => pollOnce(), 8000)
235
+
236
+ // Periodic polling
237
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
238
+ console.log(`[RevisionWatcher] Started (polling every ${POLL_INTERVAL_MS / 1000}s)`)
239
+ }
240
+
241
+ /**
242
+ * Stop the revision watcher daemon.
243
+ */
244
+ function stop() {
245
+ if (pollTimer) {
246
+ clearInterval(pollTimer)
247
+ pollTimer = null
248
+ console.log('[RevisionWatcher] Stopped')
249
+ }
250
+ }
251
+
252
+ module.exports = { start, stop, pollOnce }