@geekbeer/minion 2.53.3 → 2.56.1
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/lib/capability-checker.js +44 -74
- package/core/lib/package-scanner.js +370 -0
- package/core/lib/platform.js +0 -2
- package/core/lib/running-tasks.js +125 -0
- package/core/lib/step-poller.js +6 -5
- package/core/routes/health.js +18 -12
- package/core/routes/skills.js +7 -5
- package/core/stores/variable-store.js +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/environment-setup.md +15 -9
- package/docs/task-guides.md +25 -2
- package/linux/routes/chat.js +2 -32
- package/linux/routine-runner.js +16 -75
- package/linux/server.js +22 -5
- package/linux/workflow-runner.js +15 -29
- package/package.json +1 -1
- package/roles/engineer.md +1 -4
- package/rules/core.md +7 -3
- package/win/lib/process-manager.js +9 -1
- package/win/routes/chat.js +1 -17
- package/win/routine-runner.js +2 -39
- package/win/server.js +19 -4
- package/win/workflow-runner.js +3 -13
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Capability checker
|
|
3
3
|
*
|
|
4
|
-
* Detects MCP servers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Detects MCP servers, installed packages, and environment variables.
|
|
5
|
+
* Always returns fresh results — called on-demand, not on every heartbeat.
|
|
6
|
+
*
|
|
7
|
+
* - MCP servers: parsed from ~/.mcp.json
|
|
8
|
+
* - Packages: scanned from package managers (apt, npm, pip, etc.)
|
|
9
|
+
* - Env var keys: collected from minion-local variables/secrets stores
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
const fs = require('fs')
|
|
10
13
|
const path = require('path')
|
|
11
|
-
const { execSync } = require('child_process')
|
|
12
14
|
const { config } = require('../config')
|
|
13
|
-
const { IS_WINDOWS, buildExtendedPath } = require('./platform')
|
|
14
15
|
const variableStore = require('../stores/variable-store')
|
|
15
|
-
|
|
16
|
-
const CACHE_TTL_MS = 300000 // 5 minutes
|
|
17
|
-
|
|
18
|
-
let cachedResult = null
|
|
19
|
-
let cachedAt = 0
|
|
16
|
+
const { scanAllPackages, checkPackages } = require('./package-scanner')
|
|
20
17
|
|
|
21
18
|
/**
|
|
22
19
|
* Detect MCP servers from ~/.mcp.json.
|
|
@@ -39,46 +36,6 @@ function getMcpServers() {
|
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
/**
|
|
43
|
-
* Check if a CLI tool is available and get its version.
|
|
44
|
-
* @param {string} name - Tool name (e.g., 'git', 'node')
|
|
45
|
-
* @returns {{ name: string, available: boolean, version?: string }}
|
|
46
|
-
*/
|
|
47
|
-
function checkTool(name) {
|
|
48
|
-
const extendedPath = buildExtendedPath(config.HOME_DIR)
|
|
49
|
-
const env = {
|
|
50
|
-
...process.env,
|
|
51
|
-
HOME: config.HOME_DIR,
|
|
52
|
-
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
53
|
-
PATH: extendedPath,
|
|
54
|
-
}
|
|
55
|
-
const execOpts = { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', env }
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
// Check existence
|
|
59
|
-
const whichCmd = IS_WINDOWS ? 'where' : 'which'
|
|
60
|
-
execSync(`${whichCmd} ${name}`, execOpts)
|
|
61
|
-
|
|
62
|
-
// Get version
|
|
63
|
-
let version = null
|
|
64
|
-
try {
|
|
65
|
-
const out = execSync(`${name} --version`, execOpts).trim()
|
|
66
|
-
// Extract version number from output (e.g., "git version 2.43.0" → "2.43.0")
|
|
67
|
-
const match = out.match(/(\d+\.\d+[\.\d]*)/)
|
|
68
|
-
if (match) version = match[1]
|
|
69
|
-
} catch {
|
|
70
|
-
// Tool exists but --version failed, that's ok
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return { name, available: true, ...(version && { version }) }
|
|
74
|
-
} catch {
|
|
75
|
-
return { name, available: false }
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** CLI tools to detect */
|
|
80
|
-
const TOOL_NAMES = ['git', 'node', 'npx', 'claude', 'docker', 'tmux']
|
|
81
|
-
|
|
82
39
|
/**
|
|
83
40
|
* Get all available environment variable keys (variables + secrets).
|
|
84
41
|
* Returns deduplicated key names without values (secrets stay hidden).
|
|
@@ -91,38 +48,51 @@ function getEnvVarKeys() {
|
|
|
91
48
|
}
|
|
92
49
|
|
|
93
50
|
/**
|
|
94
|
-
* Get all capabilities (MCP servers +
|
|
95
|
-
*
|
|
51
|
+
* Get all capabilities (MCP servers + installed packages + env var keys).
|
|
52
|
+
* Always returns fresh results.
|
|
53
|
+
* @returns {{ mcp_servers: { name: string, configured: boolean }[], packages: Record<string, { available: boolean, items: { name: string, version: string }[] }>, env_var_keys: string[] }}
|
|
96
54
|
*/
|
|
97
55
|
function getCapabilities() {
|
|
98
|
-
const now = Date.now()
|
|
99
|
-
if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
|
|
100
|
-
return cachedResult
|
|
101
|
-
}
|
|
102
|
-
|
|
103
56
|
const mcp_servers = getMcpServers()
|
|
104
|
-
const
|
|
57
|
+
const packages = scanAllPackages()
|
|
105
58
|
const env_var_keys = getEnvVarKeys()
|
|
106
59
|
|
|
107
|
-
|
|
108
|
-
cachedAt = now
|
|
109
|
-
return cachedResult
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Clear the cached capability status */
|
|
113
|
-
function clearCapabilityCache() {
|
|
114
|
-
cachedResult = null
|
|
115
|
-
cachedAt = 0
|
|
60
|
+
return { mcp_servers, packages, env_var_keys }
|
|
116
61
|
}
|
|
117
62
|
|
|
118
63
|
/**
|
|
119
|
-
* Check
|
|
120
|
-
*
|
|
121
|
-
* @
|
|
122
|
-
* @returns {{ name: string, available: boolean, version?: string }[]}
|
|
64
|
+
* Check requirements against current capabilities.
|
|
65
|
+
* @param {{ mcp_servers?: string[], packages?: Record<string, string[]>, env_vars?: string[] }} requires
|
|
66
|
+
* @returns {{ satisfied: boolean, missing: { mcp_servers: string[], packages: Record<string, string[]>, env_vars: string[] } }}
|
|
123
67
|
*/
|
|
124
|
-
function
|
|
125
|
-
|
|
68
|
+
function checkRequirements(requires) {
|
|
69
|
+
const missing = { mcp_servers: [], packages: {}, env_vars: [] }
|
|
70
|
+
let satisfied = true
|
|
71
|
+
|
|
72
|
+
// Check MCP servers
|
|
73
|
+
if (requires.mcp_servers && requires.mcp_servers.length > 0) {
|
|
74
|
+
const configuredServers = getMcpServers()
|
|
75
|
+
const configuredNames = new Set(configuredServers.map(s => s.name))
|
|
76
|
+
missing.mcp_servers = requires.mcp_servers.filter(s => !configuredNames.has(s))
|
|
77
|
+
if (missing.mcp_servers.length > 0) satisfied = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check packages
|
|
81
|
+
if (requires.packages && Object.keys(requires.packages).length > 0) {
|
|
82
|
+
const installed = scanAllPackages()
|
|
83
|
+
const result = checkPackages(requires.packages, installed)
|
|
84
|
+
missing.packages = result.missing
|
|
85
|
+
if (!result.satisfied) satisfied = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check env vars
|
|
89
|
+
if (requires.env_vars && requires.env_vars.length > 0) {
|
|
90
|
+
const envVarKeys = new Set(getEnvVarKeys())
|
|
91
|
+
missing.env_vars = requires.env_vars.filter(v => !envVarKeys.has(v))
|
|
92
|
+
if (missing.env_vars.length > 0) satisfied = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { satisfied, missing }
|
|
126
96
|
}
|
|
127
97
|
|
|
128
|
-
module.exports = { getCapabilities,
|
|
98
|
+
module.exports = { getCapabilities, checkRequirements }
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans installed packages from various package managers.
|
|
5
|
+
* Each scanner checks if the package manager is available, then lists installed packages.
|
|
6
|
+
* Returns results grouped by package manager with availability status.
|
|
7
|
+
*
|
|
8
|
+
* Supported package managers:
|
|
9
|
+
* Linux: apt, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
10
|
+
* macOS: brew, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
11
|
+
* Windows: winget, scoop, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process')
|
|
15
|
+
const { IS_WINDOWS, buildExtendedPath } = require('./platform')
|
|
16
|
+
const { config } = require('../config')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build exec options with extended PATH.
|
|
20
|
+
* @returns {object}
|
|
21
|
+
*/
|
|
22
|
+
function execOpts() {
|
|
23
|
+
const extendedPath = buildExtendedPath(config.HOME_DIR)
|
|
24
|
+
return {
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
timeout: 30000,
|
|
27
|
+
stdio: 'pipe',
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
HOME: config.HOME_DIR,
|
|
31
|
+
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
32
|
+
PATH: extendedPath,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a command exists.
|
|
39
|
+
* @param {string} cmd
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function commandExists(cmd) {
|
|
43
|
+
try {
|
|
44
|
+
const whichCmd = IS_WINDOWS ? 'where' : 'which'
|
|
45
|
+
execSync(`${whichCmd} ${cmd}`, execOpts())
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a command and return stdout, or null on failure.
|
|
54
|
+
* @param {string} cmd
|
|
55
|
+
* @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
function run(cmd) {
|
|
58
|
+
try {
|
|
59
|
+
return execSync(cmd, execOpts()).trim()
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {{ name: string, version: string }} PackageItem
|
|
67
|
+
* @typedef {{ available: boolean, items: PackageItem[] }} ScanResult
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
// --- Individual scanners ---
|
|
71
|
+
|
|
72
|
+
/** @returns {ScanResult} */
|
|
73
|
+
function scanApt() {
|
|
74
|
+
if (IS_WINDOWS || process.platform === 'darwin') return { available: false, items: [] }
|
|
75
|
+
if (!commandExists('dpkg-query')) return { available: false, items: [] }
|
|
76
|
+
|
|
77
|
+
const out = run("dpkg-query -W -f '${Package}\\t${Version}\\n'")
|
|
78
|
+
if (!out) return { available: true, items: [] }
|
|
79
|
+
|
|
80
|
+
const items = out.split('\n').filter(Boolean).map(line => {
|
|
81
|
+
const [name, version] = line.split('\t')
|
|
82
|
+
return { name: name || '', version: version || '' }
|
|
83
|
+
}).filter(i => i.name)
|
|
84
|
+
|
|
85
|
+
return { available: true, items }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @returns {ScanResult} */
|
|
89
|
+
function scanBrew() {
|
|
90
|
+
if (process.platform !== 'darwin') return { available: false, items: [] }
|
|
91
|
+
if (!commandExists('brew')) return { available: false, items: [] }
|
|
92
|
+
|
|
93
|
+
const out = run('brew list --versions')
|
|
94
|
+
if (!out) return { available: true, items: [] }
|
|
95
|
+
|
|
96
|
+
const items = out.split('\n').filter(Boolean).map(line => {
|
|
97
|
+
const parts = line.split(/\s+/)
|
|
98
|
+
return { name: parts[0] || '', version: parts.slice(1).join(' ') || '' }
|
|
99
|
+
}).filter(i => i.name)
|
|
100
|
+
|
|
101
|
+
return { available: true, items }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns {ScanResult} */
|
|
105
|
+
function scanWinget() {
|
|
106
|
+
if (!IS_WINDOWS) return { available: false, items: [] }
|
|
107
|
+
if (!commandExists('winget')) return { available: false, items: [] }
|
|
108
|
+
|
|
109
|
+
const out = run('winget list --disable-interactivity --accept-source-agreements 2>nul')
|
|
110
|
+
if (!out) return { available: true, items: [] }
|
|
111
|
+
|
|
112
|
+
// winget list output is table-formatted; parse lines after the header separator
|
|
113
|
+
const lines = out.split('\n')
|
|
114
|
+
const sepIndex = lines.findIndex(l => /^-{2,}/.test(l.trim()))
|
|
115
|
+
if (sepIndex < 0) return { available: true, items: [] }
|
|
116
|
+
|
|
117
|
+
const items = []
|
|
118
|
+
for (let i = sepIndex + 1; i < lines.length; i++) {
|
|
119
|
+
const line = lines[i].trim()
|
|
120
|
+
if (!line) continue
|
|
121
|
+
// Columns are variable-width; extract name and version heuristically
|
|
122
|
+
const parts = line.split(/\s{2,}/)
|
|
123
|
+
if (parts.length >= 2) {
|
|
124
|
+
items.push({ name: parts[0], version: parts[1] || '' })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { available: true, items }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @returns {ScanResult} */
|
|
132
|
+
function scanScoop() {
|
|
133
|
+
if (!IS_WINDOWS) return { available: false, items: [] }
|
|
134
|
+
if (!commandExists('scoop')) return { available: false, items: [] }
|
|
135
|
+
|
|
136
|
+
const out = run('scoop list 2>nul')
|
|
137
|
+
if (!out) return { available: true, items: [] }
|
|
138
|
+
|
|
139
|
+
// scoop list output: " name version source ..."
|
|
140
|
+
const lines = out.split('\n')
|
|
141
|
+
const sepIndex = lines.findIndex(l => /^-{2,}/.test(l.trim()))
|
|
142
|
+
|
|
143
|
+
const items = []
|
|
144
|
+
const startIdx = sepIndex >= 0 ? sepIndex + 1 : 0
|
|
145
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
146
|
+
const line = lines[i].trim()
|
|
147
|
+
if (!line || line.startsWith('-')) continue
|
|
148
|
+
const parts = line.split(/\s+/)
|
|
149
|
+
if (parts.length >= 2) {
|
|
150
|
+
items.push({ name: parts[0], version: parts[1] || '' })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { available: true, items }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @returns {ScanResult} */
|
|
158
|
+
function scanNpm() {
|
|
159
|
+
if (!commandExists('npm')) return { available: false, items: [] }
|
|
160
|
+
|
|
161
|
+
const out = run('npm list -g --depth=0 --json')
|
|
162
|
+
if (!out) return { available: true, items: [] }
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(out)
|
|
166
|
+
const deps = data.dependencies || {}
|
|
167
|
+
const items = Object.entries(deps).map(([name, info]) => ({
|
|
168
|
+
name,
|
|
169
|
+
version: (typeof info === 'object' && info !== null ? info.version : '') || '',
|
|
170
|
+
}))
|
|
171
|
+
return { available: true, items }
|
|
172
|
+
} catch {
|
|
173
|
+
return { available: true, items: [] }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @returns {ScanResult} */
|
|
178
|
+
function scanPnpm() {
|
|
179
|
+
if (!commandExists('pnpm')) return { available: false, items: [] }
|
|
180
|
+
|
|
181
|
+
const out = run('pnpm list -g --json')
|
|
182
|
+
if (!out) return { available: true, items: [] }
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const data = JSON.parse(out)
|
|
186
|
+
// pnpm list -g --json returns an array
|
|
187
|
+
const list = Array.isArray(data) ? data : [data]
|
|
188
|
+
const items = []
|
|
189
|
+
for (const entry of list) {
|
|
190
|
+
const deps = { ...(entry.dependencies || {}), ...(entry.devDependencies || {}) }
|
|
191
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
192
|
+
items.push({
|
|
193
|
+
name,
|
|
194
|
+
version: (typeof info === 'object' && info !== null ? info.version : '') || '',
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { available: true, items }
|
|
199
|
+
} catch {
|
|
200
|
+
return { available: true, items: [] }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @returns {ScanResult} */
|
|
205
|
+
function scanBun() {
|
|
206
|
+
if (!commandExists('bun')) return { available: false, items: [] }
|
|
207
|
+
|
|
208
|
+
// bun pm ls -g outputs text format
|
|
209
|
+
const out = run('bun pm ls -g 2>/dev/null || bun pm ls -g 2>nul')
|
|
210
|
+
if (!out) return { available: true, items: [] }
|
|
211
|
+
|
|
212
|
+
const items = []
|
|
213
|
+
for (const line of out.split('\n')) {
|
|
214
|
+
// Lines like: "├── package-name@1.0.0"
|
|
215
|
+
const match = line.match(/[├└─│\s]*(.+?)@(\S+)/)
|
|
216
|
+
if (match) {
|
|
217
|
+
items.push({ name: match[1], version: match[2] })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { available: true, items }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @returns {ScanResult} */
|
|
225
|
+
function scanPip() {
|
|
226
|
+
// Try pip3 first, then pip
|
|
227
|
+
const pipCmd = commandExists('pip3') ? 'pip3' : commandExists('pip') ? 'pip' : null
|
|
228
|
+
if (!pipCmd) return { available: false, items: [] }
|
|
229
|
+
|
|
230
|
+
const out = run(`${pipCmd} list --format=json`)
|
|
231
|
+
if (!out) return { available: true, items: [] }
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const data = JSON.parse(out)
|
|
235
|
+
const items = data.map(pkg => ({
|
|
236
|
+
name: pkg.name || '',
|
|
237
|
+
version: pkg.version || '',
|
|
238
|
+
})).filter(i => i.name)
|
|
239
|
+
return { available: true, items }
|
|
240
|
+
} catch {
|
|
241
|
+
return { available: true, items: [] }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @returns {ScanResult} */
|
|
246
|
+
function scanUv() {
|
|
247
|
+
if (!commandExists('uv')) return { available: false, items: [] }
|
|
248
|
+
|
|
249
|
+
const out = run('uv pip list --format=json 2>/dev/null || uv pip list --format=json 2>nul')
|
|
250
|
+
if (!out) return { available: true, items: [] }
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const data = JSON.parse(out)
|
|
254
|
+
const items = data.map(pkg => ({
|
|
255
|
+
name: pkg.name || '',
|
|
256
|
+
version: pkg.version || '',
|
|
257
|
+
})).filter(i => i.name)
|
|
258
|
+
return { available: true, items }
|
|
259
|
+
} catch {
|
|
260
|
+
return { available: true, items: [] }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @returns {ScanResult} */
|
|
265
|
+
function scanPipx() {
|
|
266
|
+
if (!commandExists('pipx')) return { available: false, items: [] }
|
|
267
|
+
|
|
268
|
+
const out = run('pipx list --json')
|
|
269
|
+
if (!out) return { available: true, items: [] }
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const data = JSON.parse(out)
|
|
273
|
+
const venvs = data.venvs || {}
|
|
274
|
+
const items = Object.entries(venvs).map(([name, info]) => ({
|
|
275
|
+
name,
|
|
276
|
+
version: (info && info.metadata && info.metadata.main_package && info.metadata.main_package.package_version) || '',
|
|
277
|
+
}))
|
|
278
|
+
return { available: true, items }
|
|
279
|
+
} catch {
|
|
280
|
+
return { available: true, items: [] }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @returns {ScanResult} */
|
|
285
|
+
function scanCargo() {
|
|
286
|
+
if (!commandExists('cargo')) return { available: false, items: [] }
|
|
287
|
+
|
|
288
|
+
const out = run('cargo install --list')
|
|
289
|
+
if (!out) return { available: true, items: [] }
|
|
290
|
+
|
|
291
|
+
// Lines like: "ripgrep v14.1.0:" (top-level, no indent = package name)
|
|
292
|
+
const items = []
|
|
293
|
+
for (const line of out.split('\n')) {
|
|
294
|
+
if (line.startsWith(' ') || line.startsWith('\t') || !line.trim()) continue
|
|
295
|
+
const match = line.match(/^(\S+)\s+v?(\S+):?/)
|
|
296
|
+
if (match) {
|
|
297
|
+
items.push({ name: match[1], version: match[2].replace(/:$/, '') })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { available: true, items }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- Scanner registry ---
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* All supported package manager scanners.
|
|
308
|
+
* Each scanner handles its own platform check internally.
|
|
309
|
+
* @type {Record<string, () => ScanResult>}
|
|
310
|
+
*/
|
|
311
|
+
const SCANNERS = {
|
|
312
|
+
apt: scanApt,
|
|
313
|
+
brew: scanBrew,
|
|
314
|
+
winget: scanWinget,
|
|
315
|
+
scoop: scanScoop,
|
|
316
|
+
npm: scanNpm,
|
|
317
|
+
pnpm: scanPnpm,
|
|
318
|
+
bun: scanBun,
|
|
319
|
+
pip: scanPip,
|
|
320
|
+
uv: scanUv,
|
|
321
|
+
pipx: scanPipx,
|
|
322
|
+
cargo: scanCargo,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Scan all package managers and return installed packages.
|
|
327
|
+
* Skips package managers not available on the current platform.
|
|
328
|
+
* @returns {Record<string, ScanResult>}
|
|
329
|
+
*/
|
|
330
|
+
function scanAllPackages() {
|
|
331
|
+
const results = {}
|
|
332
|
+
for (const [name, scanner] of Object.entries(SCANNERS)) {
|
|
333
|
+
results[name] = scanner()
|
|
334
|
+
}
|
|
335
|
+
return results
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check if specific packages are installed for given package managers.
|
|
340
|
+
* @param {Record<string, string[]>} required - e.g. { apt: ['ffmpeg'], npm: ['@anthropic-ai/claude-code'] }
|
|
341
|
+
* @param {Record<string, ScanResult>} installed - result from scanAllPackages()
|
|
342
|
+
* @returns {{ satisfied: boolean, missing: Record<string, string[]> }}
|
|
343
|
+
*/
|
|
344
|
+
function checkPackages(required, installed) {
|
|
345
|
+
const missing = {}
|
|
346
|
+
let satisfied = true
|
|
347
|
+
|
|
348
|
+
for (const [manager, packages] of Object.entries(required)) {
|
|
349
|
+
const scan = installed[manager]
|
|
350
|
+
if (!scan || !scan.available) {
|
|
351
|
+
// Package manager not available — all packages are missing
|
|
352
|
+
if (packages.length > 0) {
|
|
353
|
+
missing[manager] = [...packages]
|
|
354
|
+
satisfied = false
|
|
355
|
+
}
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const installedNames = new Set(scan.items.map(i => i.name))
|
|
360
|
+
const missingPkgs = packages.filter(p => !installedNames.has(p))
|
|
361
|
+
if (missingPkgs.length > 0) {
|
|
362
|
+
missing[manager] = missingPkgs
|
|
363
|
+
satisfied = false
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { satisfied, missing }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { scanAllPackages, checkPackages, SCANNERS }
|
package/core/lib/platform.js
CHANGED
|
@@ -31,7 +31,6 @@ function resolveDataDir() {
|
|
|
31
31
|
|
|
32
32
|
const DATA_DIR = resolveDataDir()
|
|
33
33
|
const LOG_DIR = path.join(DATA_DIR, 'logs')
|
|
34
|
-
const MARKER_DIR = path.join(TEMP_DIR, 'minion-executions')
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
36
|
* Build extended PATH including common CLI installation locations.
|
|
@@ -104,7 +103,6 @@ module.exports = {
|
|
|
104
103
|
TEMP_DIR,
|
|
105
104
|
DATA_DIR,
|
|
106
105
|
LOG_DIR,
|
|
107
|
-
MARKER_DIR,
|
|
108
106
|
buildExtendedPath,
|
|
109
107
|
getExitCodePath,
|
|
110
108
|
getDefaultShell,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running Tasks Store
|
|
3
|
+
*
|
|
4
|
+
* In-memory store that tracks what tmux sessions are currently running
|
|
5
|
+
* on this minion. Reported to HQ via heartbeat so the dashboard can
|
|
6
|
+
* show the minion's actual state (the "body" of the minion).
|
|
7
|
+
*
|
|
8
|
+
* Mutations trigger an immediate heartbeat (debounced to 2s) so the
|
|
9
|
+
* dashboard receives near-real-time updates.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { isHqConfigured } = require('../config')
|
|
13
|
+
|
|
14
|
+
/** @type {Array<import('../../src/lib/supabase/types').RunningTask>} */
|
|
15
|
+
let tasks = []
|
|
16
|
+
|
|
17
|
+
// Debounce timer for immediate heartbeat on mutation
|
|
18
|
+
let debounceTimer = null
|
|
19
|
+
const DEBOUNCE_MS = 2000
|
|
20
|
+
|
|
21
|
+
// Lazy reference — resolved on first use to avoid circular require
|
|
22
|
+
let _sendHeartbeat = null
|
|
23
|
+
let _getStatus = null
|
|
24
|
+
let _version = null
|
|
25
|
+
|
|
26
|
+
function getSendHeartbeat() {
|
|
27
|
+
if (!_sendHeartbeat) {
|
|
28
|
+
_sendHeartbeat = require('../api').sendHeartbeat
|
|
29
|
+
}
|
|
30
|
+
return _sendHeartbeat
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getStatusFn() {
|
|
34
|
+
if (!_getStatus) {
|
|
35
|
+
_getStatus = require('../routes/health').getStatus
|
|
36
|
+
}
|
|
37
|
+
return _getStatus
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getVersion() {
|
|
41
|
+
if (!_version) {
|
|
42
|
+
_version = require('../../package.json').version
|
|
43
|
+
}
|
|
44
|
+
return _version
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Push a status change to HQ immediately (debounced).
|
|
49
|
+
*/
|
|
50
|
+
function pushToHQ() {
|
|
51
|
+
if (!isHqConfigured()) return
|
|
52
|
+
|
|
53
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
54
|
+
debounceTimer = setTimeout(() => {
|
|
55
|
+
debounceTimer = null
|
|
56
|
+
try {
|
|
57
|
+
const { currentStatus, currentTask } = getStatusFn()
|
|
58
|
+
getSendHeartbeat()({
|
|
59
|
+
status: currentStatus,
|
|
60
|
+
current_task: currentTask,
|
|
61
|
+
running_tasks: tasks,
|
|
62
|
+
version: getVersion(),
|
|
63
|
+
}).catch(err => {
|
|
64
|
+
console.error('[RunningTasks] Heartbeat push failed:', err.message)
|
|
65
|
+
})
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('[RunningTasks] Failed to push heartbeat:', err.message)
|
|
68
|
+
}
|
|
69
|
+
}, DEBOUNCE_MS)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Add a running task.
|
|
74
|
+
* @param {object} entry
|
|
75
|
+
* @param {'workflow'|'routine'|'directive'} entry.type
|
|
76
|
+
* @param {string} [entry.workflow_execution_id]
|
|
77
|
+
* @param {number} [entry.step_index]
|
|
78
|
+
* @param {string} [entry.routine_id]
|
|
79
|
+
* @param {string} entry.session_name
|
|
80
|
+
* @param {string} [entry.started_at] - ISO timestamp, defaults to now
|
|
81
|
+
*/
|
|
82
|
+
function add(entry) {
|
|
83
|
+
const task = {
|
|
84
|
+
type: entry.type,
|
|
85
|
+
session_name: entry.session_name,
|
|
86
|
+
started_at: entry.started_at || new Date().toISOString(),
|
|
87
|
+
}
|
|
88
|
+
if (entry.workflow_execution_id) task.workflow_execution_id = entry.workflow_execution_id
|
|
89
|
+
if (entry.step_index != null) task.step_index = entry.step_index
|
|
90
|
+
if (entry.routine_id) task.routine_id = entry.routine_id
|
|
91
|
+
|
|
92
|
+
tasks.push(task)
|
|
93
|
+
console.log(`[RunningTasks] Added: ${task.type} session=${task.session_name}`)
|
|
94
|
+
pushToHQ()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Remove a running task by session name.
|
|
99
|
+
* @param {string} sessionName
|
|
100
|
+
*/
|
|
101
|
+
function remove(sessionName) {
|
|
102
|
+
const before = tasks.length
|
|
103
|
+
tasks = tasks.filter(t => t.session_name !== sessionName)
|
|
104
|
+
if (tasks.length < before) {
|
|
105
|
+
console.log(`[RunningTasks] Removed: session=${sessionName}`)
|
|
106
|
+
pushToHQ()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all currently running tasks.
|
|
112
|
+
* @returns {Array}
|
|
113
|
+
*/
|
|
114
|
+
function getAll() {
|
|
115
|
+
return [...tasks]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clear all tasks (used on shutdown).
|
|
120
|
+
*/
|
|
121
|
+
function clear() {
|
|
122
|
+
tasks = []
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { add, remove, getAll, clear }
|
package/core/lib/step-poller.js
CHANGED
|
@@ -94,7 +94,7 @@ async function executeStep(step) {
|
|
|
94
94
|
assigned_role,
|
|
95
95
|
skill_name,
|
|
96
96
|
revision_feedback,
|
|
97
|
-
|
|
97
|
+
template_vars,
|
|
98
98
|
} = step
|
|
99
99
|
|
|
100
100
|
console.log(
|
|
@@ -125,9 +125,13 @@ async function executeStep(step) {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// 2. Fetch the skill from HQ to ensure it's deployed locally
|
|
128
|
+
// Pass template_vars as ?vars= so HQ expands {{VAR_NAME}} in SKILL.md
|
|
128
129
|
if (skill_name) {
|
|
129
130
|
try {
|
|
130
|
-
const
|
|
131
|
+
const varsParam = template_vars
|
|
132
|
+
? `?vars=${Buffer.from(JSON.stringify(template_vars)).toString('base64')}`
|
|
133
|
+
: ''
|
|
134
|
+
const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}${varsParam}`
|
|
131
135
|
const fetchResp = await fetch(fetchUrl, {
|
|
132
136
|
method: 'POST',
|
|
133
137
|
headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
|
|
@@ -152,9 +156,6 @@ async function executeStep(step) {
|
|
|
152
156
|
if (revision_feedback) {
|
|
153
157
|
runPayload.revision_feedback = revision_feedback
|
|
154
158
|
}
|
|
155
|
-
if (extra_env && typeof extra_env === 'object') {
|
|
156
|
-
runPayload.extra_env = extra_env
|
|
157
|
-
}
|
|
158
159
|
|
|
159
160
|
const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
|
|
160
161
|
const runResp = await fetch(runUrl, {
|