@geekbeer/minion 2.53.2 → 2.54.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/routes/health.js +16 -11
- package/docs/environment-setup.md +199 -0
- package/docs/task-guides.md +19 -2
- package/linux/server.js +4 -4
- package/package.json +1 -1
- package/rules/core.md +1 -0
- package/win/lib/process-manager.js +9 -1
- package/win/server.js +4 -4
|
@@ -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/routes/health.js
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* - GET /api/health - Health check
|
|
6
6
|
* - GET /api/status - Get current status
|
|
7
7
|
* - POST /api/status - Update status
|
|
8
|
-
* -
|
|
8
|
+
* - GET /api/capabilities - Get current capabilities (MCP servers, packages, env vars)
|
|
9
|
+
* - POST /api/capabilities/check - Check requirements (MCP servers, packages, env vars)
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const { version } = require('../../package.json')
|
|
12
13
|
const { config, isHqConfigured } = require('../config')
|
|
13
14
|
const { sendHeartbeat } = require('../api')
|
|
14
15
|
const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
|
|
15
|
-
const { getCapabilities,
|
|
16
|
+
const { getCapabilities, checkRequirements } = require('../lib/capability-checker')
|
|
16
17
|
|
|
17
18
|
function maskToken(token) {
|
|
18
19
|
if (!token || token.length < 8) return token ? '***' : ''
|
|
@@ -64,7 +65,6 @@ async function healthRoutes(fastify) {
|
|
|
64
65
|
timestamp: new Date().toISOString(),
|
|
65
66
|
llm_services: getLlmServices(),
|
|
66
67
|
llm_command_configured: isLlmCommandConfigured(),
|
|
67
|
-
capabilities: getCapabilities(),
|
|
68
68
|
env: {
|
|
69
69
|
HQ_URL: config.HQ_URL || '',
|
|
70
70
|
MINION_ID: config.MINION_ID || '',
|
|
@@ -107,16 +107,21 @@ async function healthRoutes(fastify) {
|
|
|
107
107
|
return { success: true }
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
//
|
|
111
|
-
fastify.
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
// Get current capabilities (MCP servers, installed packages, env var keys)
|
|
111
|
+
fastify.get('/api/capabilities', async () => {
|
|
112
|
+
return getCapabilities()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Check requirements against current capabilities
|
|
116
|
+
fastify.post('/api/capabilities/check', async (request, reply) => {
|
|
117
|
+
const { mcp_servers, packages, env_vars } = request.body || {}
|
|
118
|
+
|
|
119
|
+
if (!mcp_servers && !packages && !env_vars) {
|
|
114
120
|
reply.code(400)
|
|
115
|
-
return { error: '
|
|
121
|
+
return { error: 'At least one of mcp_servers, packages, or env_vars must be provided' }
|
|
116
122
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { cli_tools: checkTools(limited) }
|
|
123
|
+
|
|
124
|
+
return checkRequirements({ mcp_servers, packages, env_vars })
|
|
120
125
|
})
|
|
121
126
|
}
|
|
122
127
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# 環境セットアップガイド
|
|
2
|
+
|
|
3
|
+
スキルが必要とする MCP サーバーや CLI ツールのインストール・設定手順です。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 事前チェック(Readiness Check)の仕組み
|
|
8
|
+
|
|
9
|
+
スキルの `requires` フロントマターで宣言された依存関係は、ワークフロー実行前に事前チェックされる。
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
requires:
|
|
13
|
+
mcp_servers: [playwright]
|
|
14
|
+
packages:
|
|
15
|
+
apt: [jq, imagemagick]
|
|
16
|
+
env_vars: [API_KEY]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| 種別 | 検出方法 | チェック対象 |
|
|
20
|
+
|------|---------|-------------|
|
|
21
|
+
| `mcp_servers` | `~/.mcp.json` を読み取り、`mcpServers` キーにサーバー名が存在するか確認 | 設定ファイルの有無のみ(実際の起動テストはしない) |
|
|
22
|
+
| `packages` | 各パッケージマネージャーの list コマンドでインストール済みパッケージを取得し、突合 | パッケージの存在 |
|
|
23
|
+
| `env_vars` | ミニオンローカル変数/シークレット + HQ注入変数 | キーの存在 |
|
|
24
|
+
|
|
25
|
+
**重要**: MCP サーバーは `~/.mcp.json` に設定しないと検出されない。npm パッケージをインストールしただけでは不十分。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## MCP サーバーの設定
|
|
30
|
+
|
|
31
|
+
MCP サーバーは `~/.mcp.json` に JSON 形式で設定する。このファイルが Claude Code セッション起動時に読み込まれる。
|
|
32
|
+
|
|
33
|
+
### ファイル形式
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"<server-name>": {
|
|
39
|
+
"command": "<起動コマンド>",
|
|
40
|
+
"args": ["<引数1>", "<引数2>"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 設定の追加・変更手順
|
|
47
|
+
|
|
48
|
+
1. `~/.mcp.json` が存在しない場合は新規作成する
|
|
49
|
+
2. 既存の場合は内容を読み取り、`mcpServers` オブジェクトにエントリを追加する
|
|
50
|
+
3. 既存エントリを壊さないよう注意する
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 既存ファイルの確認
|
|
54
|
+
cat ~/.mcp.json 2>/dev/null || echo '(not found)'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### よく使う MCP サーバーの設定例
|
|
58
|
+
|
|
59
|
+
#### Playwright(ブラウザ自動化)
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"playwright": {
|
|
65
|
+
"command": "npx",
|
|
66
|
+
"args": ["-y", "@playwright/mcp@latest"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`npx -y` により、未インストールでも自動ダウンロード・実行される。事前の `npm install` は不要。
|
|
73
|
+
|
|
74
|
+
#### Supabase(データベース)
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"mcpServers": {
|
|
79
|
+
"supabase": {
|
|
80
|
+
"url": "http://<supabase-host>:54321/mcp?read_only=true&features=database,docs"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
URL ベースの MCP サーバーは `url` フィールドで指定する(`command`/`args` は不要)。
|
|
87
|
+
|
|
88
|
+
### 注意事項
|
|
89
|
+
|
|
90
|
+
- `~/.mcp.json` は手動で編集する。Claude Code の設定 UI からは変更できない
|
|
91
|
+
- `npx -y <package>` 形式を使えば、グローバルインストールなしで MCP サーバーを起動できる
|
|
92
|
+
- サーバー名はスキルの `requires.mcp_servers` と一致させる必要がある(例: `playwright`)
|
|
93
|
+
- `claude-settings.json`(`~/.claude/settings.json`)の `mcpServers` に設定しても同様に動作するが、`~/.mcp.json` が推奨
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## パッケージのインストール
|
|
98
|
+
|
|
99
|
+
スキルが `requires.packages` で宣言したパッケージは、対応するパッケージマネージャーの list コマンドで検出される。
|
|
100
|
+
|
|
101
|
+
### パッケージマネージャーの使い分け
|
|
102
|
+
|
|
103
|
+
| マネージャー | 用途 | インストール先 |
|
|
104
|
+
|-------------|------|---------------|
|
|
105
|
+
| `apt` | OS レベルのツール・ライブラリ | システム全体 (`/usr/bin/` 等) |
|
|
106
|
+
| `npm` | Node.js パッケージ・CLI ツール | `node_modules/` またはグローバル |
|
|
107
|
+
| `npx` | npm パッケージの一時実行 | 一時ディレクトリ(実行後破棄) |
|
|
108
|
+
| `pip` / `pip3` | Python パッケージ | ユーザーディレクトリまたは venv |
|
|
109
|
+
|
|
110
|
+
### apt(システムパッケージ)
|
|
111
|
+
|
|
112
|
+
画像処理ツール、テキスト処理ツール、ネットワークツールなど OS レベルのツールに使う。
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
sudo apt update && sudo apt install -y <package-name>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
例:
|
|
119
|
+
```bash
|
|
120
|
+
# 画像処理
|
|
121
|
+
sudo apt install -y imagemagick
|
|
122
|
+
|
|
123
|
+
# JSON 処理
|
|
124
|
+
sudo apt install -y jq
|
|
125
|
+
|
|
126
|
+
# PDF 処理
|
|
127
|
+
sudo apt install -y poppler-utils
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### npm(Node.js CLI ツール)
|
|
131
|
+
|
|
132
|
+
グローバルインストールで `which` に検出されるようにする。
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install -g <package-name>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
例:
|
|
139
|
+
```bash
|
|
140
|
+
# TypeScript コンパイラ
|
|
141
|
+
npm install -g typescript
|
|
142
|
+
|
|
143
|
+
# Prettier(コードフォーマッター)
|
|
144
|
+
npm install -g prettier
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**注意**: MCP サーバーのインストールに `npm install -g` は使わない。MCP サーバーは `~/.mcp.json` に `npx -y` 形式で設定する(前述の MCP サーバー設定を参照)。
|
|
148
|
+
|
|
149
|
+
### pip / pip3(Python ツール)
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip3 install --user <package-name>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`--user` を付けると `~/.local/bin/` にインストールされる。PATH に含まれていることを確認する。
|
|
156
|
+
|
|
157
|
+
例:
|
|
158
|
+
```bash
|
|
159
|
+
# AWS CLI
|
|
160
|
+
pip3 install --user awscli
|
|
161
|
+
|
|
162
|
+
# YAML 処理
|
|
163
|
+
pip3 install --user yq
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### npx(一時実行)
|
|
167
|
+
|
|
168
|
+
`npx` はインストールせずに npm パッケージを一時実行する。`packages` の事前チェックでは検出されないため、恒久的に使うツールには `npm install -g` を使うこと。
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 一時的な利用(事前チェックでは検出されない)
|
|
172
|
+
npx -y cowsay hello
|
|
173
|
+
|
|
174
|
+
# 恒久的に必要なら npm install -g を使う
|
|
175
|
+
npm install -g cowsay
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## インストール後の確認
|
|
181
|
+
|
|
182
|
+
ツールのインストール後、事前チェックが通るか確認する。
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# CLI ツールが検出されるか確認
|
|
186
|
+
which <tool-name>
|
|
187
|
+
|
|
188
|
+
# MCP サーバーが設定されているか確認
|
|
189
|
+
cat ~/.mcp.json | node -e "
|
|
190
|
+
const cfg = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
|
|
191
|
+
const servers = cfg.mcpServers || cfg.servers || {};
|
|
192
|
+
console.log('Configured MCP servers:', Object.keys(servers).join(', ') || '(none)');
|
|
193
|
+
"
|
|
194
|
+
|
|
195
|
+
# エージェントのキャパビリティキャッシュをクリアして再検出させる
|
|
196
|
+
# (キャパビリティは 5 分間キャッシュされるため、即座に反映したい場合)
|
|
197
|
+
curl -s -X POST "http://localhost:8080/api/capabilities/clear-cache" \
|
|
198
|
+
-H "Authorization: Bearer $API_TOKEN"
|
|
199
|
+
```
|
package/docs/task-guides.md
CHANGED
|
@@ -23,7 +23,10 @@ name: my-skill
|
|
|
23
23
|
description: What this skill does
|
|
24
24
|
requires:
|
|
25
25
|
mcp_servers: [playwright, supabase]
|
|
26
|
-
|
|
26
|
+
packages:
|
|
27
|
+
apt: [ffmpeg]
|
|
28
|
+
npm: ["@anthropic-ai/claude-code"]
|
|
29
|
+
env_vars: [API_KEY]
|
|
27
30
|
---
|
|
28
31
|
|
|
29
32
|
Skill instructions here...
|
|
@@ -37,7 +40,8 @@ Skill instructions here...
|
|
|
37
40
|
| `description` | Yes | スキルの説明 |
|
|
38
41
|
| `requires` | No | 実行に必要な依存関係 |
|
|
39
42
|
| `requires.mcp_servers` | No | 必要な MCP サーバー名のリスト |
|
|
40
|
-
| `requires.
|
|
43
|
+
| `requires.packages` | No | 必要なパッケージ(パッケージマネージャー別に指定) |
|
|
44
|
+
| `requires.env_vars` | No | 必要な環境変数キーのリスト |
|
|
41
45
|
|
|
42
46
|
`requires` を宣言すると、ワークフロー実行前にミニオンの環境と照合する事前チェック(readiness check)が行われる。
|
|
43
47
|
未宣言の場合は常に実行可能と見なされる。
|
|
@@ -193,6 +197,19 @@ await reportIssue({ title: '...', body: '...', labels: ['bug'] })
|
|
|
193
197
|
|
|
194
198
|
---
|
|
195
199
|
|
|
200
|
+
## ツール・MCPサーバーのインストール
|
|
201
|
+
|
|
202
|
+
スキルが `requires` で宣言している MCP サーバーや CLI ツールが不足している場合は、`~/.minion/docs/environment-setup.md` の手順に従ってインストールする。
|
|
203
|
+
|
|
204
|
+
主なポイント:
|
|
205
|
+
- **MCP サーバー**: `~/.mcp.json` にエントリを追加する(`npm install` ではなく設定ファイルの編集)
|
|
206
|
+
- **CLI ツール**: `apt`(OS ツール)、`npm install -g`(Node.js ツール)、`pip3 install --user`(Python ツール)を使い分ける
|
|
207
|
+
- **確認**: インストール後 `which <tool>` や `cat ~/.mcp.json` で事前チェックが通るか確認する
|
|
208
|
+
|
|
209
|
+
詳細は `~/.minion/docs/environment-setup.md` を参照。
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
196
213
|
## トラブルシューティング
|
|
197
214
|
|
|
198
215
|
### HQ APIの仕様がわからない
|
package/linux/server.js
CHANGED
|
@@ -35,7 +35,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..')
|
|
|
35
35
|
const { config, validate, isHqConfigured } = require('../core/config')
|
|
36
36
|
const { sendHeartbeat } = require('../core/api')
|
|
37
37
|
const { version } = require('../package.json')
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
const workflowStore = require('../core/stores/workflow-store')
|
|
40
40
|
const routineStore = require('../core/stores/routine-store')
|
|
41
41
|
|
|
@@ -96,7 +96,7 @@ async function shutdown(signal) {
|
|
|
96
96
|
if (isHqConfigured()) {
|
|
97
97
|
try {
|
|
98
98
|
await Promise.race([
|
|
99
|
-
sendHeartbeat({ status: 'offline', version
|
|
99
|
+
sendHeartbeat({ status: 'offline', version }),
|
|
100
100
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
101
101
|
])
|
|
102
102
|
} catch {
|
|
@@ -331,14 +331,14 @@ async function start() {
|
|
|
331
331
|
// Send initial online heartbeat
|
|
332
332
|
const { getStatus } = require('../core/routes/health')
|
|
333
333
|
const { currentTask } = getStatus()
|
|
334
|
-
sendHeartbeat({ status: 'online', current_task: currentTask, version
|
|
334
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
|
|
335
335
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
336
336
|
})
|
|
337
337
|
|
|
338
338
|
// Start periodic heartbeat
|
|
339
339
|
heartbeatTimer = setInterval(() => {
|
|
340
340
|
const { currentStatus, currentTask } = getStatus()
|
|
341
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, version
|
|
341
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
342
342
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
|
343
343
|
})
|
|
344
344
|
}, HEARTBEAT_INTERVAL_MS)
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -60,6 +60,14 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd) {
|
|
|
60
60
|
const logPath = path.join(dataDir, 'update-agent.log')
|
|
61
61
|
|
|
62
62
|
// PowerShell script content: stop → install → start, with logging
|
|
63
|
+
// The stopCmd kills all child processes of the agent. Since this script
|
|
64
|
+
// itself is a child of the agent (spawn from Node), we must exclude our
|
|
65
|
+
// own PID ($PID) to avoid the update script killing itself.
|
|
66
|
+
const safeStopCmd = stopCmd.replace(
|
|
67
|
+
/ForEach-Object\s*\{\s*Stop-Process/,
|
|
68
|
+
'Where-Object { $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process'
|
|
69
|
+
)
|
|
70
|
+
|
|
63
71
|
const ps1 = [
|
|
64
72
|
`$ErrorActionPreference = 'Stop'`,
|
|
65
73
|
`$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
|
|
@@ -67,7 +75,7 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd) {
|
|
|
67
75
|
`Log 'Update started'`,
|
|
68
76
|
`try {`,
|
|
69
77
|
` Log 'Stopping agent...'`,
|
|
70
|
-
` ${
|
|
78
|
+
` ${safeStopCmd}`,
|
|
71
79
|
` Start-Sleep -Seconds 3`,
|
|
72
80
|
` Log 'Installing package...'`,
|
|
73
81
|
` $out = & cmd /c "${npmInstallCmd} 2>&1"`,
|
package/win/server.js
CHANGED
|
@@ -16,7 +16,7 @@ const fastify = require('fastify')({ logger: true })
|
|
|
16
16
|
const { config, validate, isHqConfigured } = require('../core/config')
|
|
17
17
|
const { sendHeartbeat } = require('../core/api')
|
|
18
18
|
const { version } = require('../package.json')
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
const workflowStore = require('../core/stores/workflow-store')
|
|
21
21
|
const routineStore = require('../core/stores/routine-store')
|
|
22
22
|
|
|
@@ -74,7 +74,7 @@ async function shutdown(signal) {
|
|
|
74
74
|
if (isHqConfigured()) {
|
|
75
75
|
try {
|
|
76
76
|
await Promise.race([
|
|
77
|
-
sendHeartbeat({ status: 'offline', version
|
|
77
|
+
sendHeartbeat({ status: 'offline', version }),
|
|
78
78
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
79
79
|
])
|
|
80
80
|
} catch {
|
|
@@ -267,14 +267,14 @@ async function start() {
|
|
|
267
267
|
// Send initial online heartbeat
|
|
268
268
|
const { getStatus } = require('../core/routes/health')
|
|
269
269
|
const { currentTask } = getStatus()
|
|
270
|
-
sendHeartbeat({ status: 'online', current_task: currentTask, version
|
|
270
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
|
|
271
271
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
274
|
// Start periodic heartbeat
|
|
275
275
|
heartbeatTimer = setInterval(() => {
|
|
276
276
|
const { currentStatus, currentTask } = getStatus()
|
|
277
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, version
|
|
277
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
278
278
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
|
279
279
|
})
|
|
280
280
|
}, HEARTBEAT_INTERVAL_MS)
|