@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.
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * Capability checker
3
3
  *
4
- * Detects MCP servers configured in ~/.mcp.json and available CLI tools.
5
- * Results are cached in memory for 5 minutes to avoid excessive filesystem/process checks.
6
- * Follows the same caching pattern as llm-checker.js.
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 + CLI tools + env var keys), cached for 5 minutes.
95
- * @returns {{ mcp_servers: { name: string, configured: boolean }[], cli_tools: { name: string, available: boolean, version?: string }[], env_var_keys: string[] }}
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 cli_tools = TOOL_NAMES.map(name => checkTool(name))
57
+ const packages = scanAllPackages()
105
58
  const env_var_keys = getEnvVarKeys()
106
59
 
107
- cachedResult = { mcp_servers, cli_tools, env_var_keys }
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 arbitrary tool names on-demand (not cached).
120
- * Used by readiness checks to verify skill-declared cli_tools.
121
- * @param {string[]} names - Tool names to check
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 checkTools(names) {
125
- return names.map(name => checkTool(name))
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, clearCapabilityCache, checkTools }
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 }
@@ -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 }
@@ -94,7 +94,7 @@ async function executeStep(step) {
94
94
  assigned_role,
95
95
  skill_name,
96
96
  revision_feedback,
97
- extra_env,
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 fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}`
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, {