@geekbeer/minion 2.44.0 → 2.46.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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Capability checker
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.
7
+ */
8
+
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+ const { execSync } = require('child_process')
12
+ const { config } = require('../config')
13
+ const { IS_WINDOWS, buildExtendedPath } = require('./platform')
14
+
15
+ const CACHE_TTL_MS = 300000 // 5 minutes
16
+
17
+ let cachedResult = null
18
+ let cachedAt = 0
19
+
20
+ /**
21
+ * Detect MCP servers from ~/.mcp.json.
22
+ * Parses the Claude Code MCP configuration and returns configured server names.
23
+ * @returns {{ name: string, configured: boolean }[]}
24
+ */
25
+ function getMcpServers() {
26
+ const mcpPath = path.join(config.HOME_DIR, '.mcp.json')
27
+ try {
28
+ if (!fs.existsSync(mcpPath)) return []
29
+ const content = fs.readFileSync(mcpPath, 'utf-8')
30
+ const parsed = JSON.parse(content)
31
+ const servers = parsed.mcpServers || parsed.servers || {}
32
+ return Object.keys(servers).map(name => ({
33
+ name,
34
+ configured: true,
35
+ }))
36
+ } catch {
37
+ return []
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if a CLI tool is available and get its version.
43
+ * @param {string} name - Tool name (e.g., 'git', 'node')
44
+ * @returns {{ name: string, available: boolean, version?: string }}
45
+ */
46
+ function checkTool(name) {
47
+ const extendedPath = buildExtendedPath(config.HOME_DIR)
48
+ const env = {
49
+ ...process.env,
50
+ HOME: config.HOME_DIR,
51
+ ...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
52
+ PATH: extendedPath,
53
+ }
54
+ const execOpts = { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', env }
55
+
56
+ try {
57
+ // Check existence
58
+ const whichCmd = IS_WINDOWS ? 'where' : 'which'
59
+ execSync(`${whichCmd} ${name}`, execOpts)
60
+
61
+ // Get version
62
+ let version = null
63
+ try {
64
+ const out = execSync(`${name} --version`, execOpts).trim()
65
+ // Extract version number from output (e.g., "git version 2.43.0" → "2.43.0")
66
+ const match = out.match(/(\d+\.\d+[\.\d]*)/)
67
+ if (match) version = match[1]
68
+ } catch {
69
+ // Tool exists but --version failed, that's ok
70
+ }
71
+
72
+ return { name, available: true, ...(version && { version }) }
73
+ } catch {
74
+ return { name, available: false }
75
+ }
76
+ }
77
+
78
+ /** CLI tools to detect */
79
+ const TOOL_NAMES = ['git', 'node', 'npx', 'claude', 'docker', 'tmux']
80
+
81
+ /**
82
+ * Get all capabilities (MCP servers + CLI tools), cached for 5 minutes.
83
+ * @returns {{ mcp_servers: { name: string, configured: boolean }[], cli_tools: { name: string, available: boolean, version?: string }[] }}
84
+ */
85
+ function getCapabilities() {
86
+ const now = Date.now()
87
+ if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
88
+ return cachedResult
89
+ }
90
+
91
+ const mcp_servers = getMcpServers()
92
+ const cli_tools = TOOL_NAMES.map(name => checkTool(name))
93
+
94
+ cachedResult = { mcp_servers, cli_tools }
95
+ cachedAt = now
96
+ return cachedResult
97
+ }
98
+
99
+ /** Clear the cached capability status */
100
+ function clearCapabilityCache() {
101
+ cachedResult = null
102
+ cachedAt = 0
103
+ }
104
+
105
+ module.exports = { getCapabilities, clearCapabilityCache }
@@ -94,6 +94,7 @@ async function executeStep(step) {
94
94
  assigned_role,
95
95
  skill_name,
96
96
  revision_feedback,
97
+ extra_env,
97
98
  } = step
98
99
 
99
100
  console.log(
@@ -151,6 +152,9 @@ async function executeStep(step) {
151
152
  if (revision_feedback) {
152
153
  runPayload.revision_feedback = revision_feedback
153
154
  }
155
+ if (extra_env && typeof extra_env === 'object') {
156
+ runPayload.extra_env = extra_env
157
+ }
154
158
 
155
159
  const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
156
160
  const runResp = await fetch(runUrl, {
@@ -11,6 +11,7 @@ const { version } = require('../../package.json')
11
11
  const { config, isHqConfigured } = require('../config')
12
12
  const { sendHeartbeat } = require('../api')
13
13
  const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
14
+ const { getCapabilities } = require('../lib/capability-checker')
14
15
 
15
16
  function maskToken(token) {
16
17
  if (!token || token.length < 8) return token ? '***' : ''
@@ -58,6 +59,7 @@ async function healthRoutes(fastify) {
58
59
  timestamp: new Date().toISOString(),
59
60
  llm_services: getLlmServices(),
60
61
  llm_command_configured: isLlmCommandConfigured(),
62
+ capabilities: getCapabilities(),
61
63
  env: {
62
64
  HQ_URL: config.HQ_URL || '',
63
65
  MINION_ID: config.MINION_ID || '',
@@ -291,7 +291,7 @@ async function skillRoutes(fastify, opts) {
291
291
  return { success: false, error: 'Unauthorized' }
292
292
  }
293
293
 
294
- const { skill_name, execution_id, step_index, workflow_name, role, revision_feedback } = request.body || {}
294
+ const { skill_name, execution_id, step_index, workflow_name, role, revision_feedback, extra_env } = request.body || {}
295
295
 
296
296
  if (!skill_name) {
297
297
  reply.code(400)
@@ -340,6 +340,7 @@ async function skillRoutes(fastify, opts) {
340
340
  const runOptions = {}
341
341
  if (role) runOptions.role = role
342
342
  if (revision_feedback) runOptions.revisionFeedback = revision_feedback
343
+ if (extra_env && typeof extra_env === 'object') runOptions.extraEnv = extra_env
343
344
 
344
345
  // Run asynchronously — respond immediately
345
346
  const executionPromise = (async () => {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Variables & Secrets routes (shared between Linux and Windows)
3
+ *
4
+ * Minion Variables:
5
+ * GET /api/variables - List all variables (key + value)
6
+ * GET /api/variables/:key - Get a single variable
7
+ * PUT /api/variables/:key - Set a variable
8
+ * DELETE /api/variables/:key - Delete a variable
9
+ *
10
+ * Minion Secrets:
11
+ * GET /api/secrets - List secret keys only (no values)
12
+ * PUT /api/secrets/:key - Set a secret
13
+ * DELETE /api/secrets/:key - Delete a secret
14
+ *
15
+ * All endpoints require Bearer token authentication.
16
+ */
17
+
18
+ const variableStore = require('../stores/variable-store')
19
+ const { verifyToken } = require('../lib/auth')
20
+
21
+ /** Validate a key name: alphanumeric + underscores, 1-100 chars */
22
+ function isValidKey(key) {
23
+ return /^[A-Za-z_][A-Za-z0-9_]{0,99}$/.test(key)
24
+ }
25
+
26
+ /** Validate a value: no newlines, max 2000 chars */
27
+ function isValidValue(value) {
28
+ if (typeof value !== 'string') return false
29
+ if (value.includes('\n') || value.includes('\r')) return false
30
+ if (value.length > 2000) return false
31
+ return true
32
+ }
33
+
34
+ function variableRoutes(fastify, _opts, done) {
35
+ // ─── Variables (non-sensitive) ───────────────────────────────────────
36
+
37
+ fastify.get('/api/variables', async (request, reply) => {
38
+ if (!verifyToken(request)) {
39
+ return reply.code(401).send({ error: 'Unauthorized' })
40
+ }
41
+ const variables = variableStore.getAll('variables')
42
+ return { success: true, variables }
43
+ })
44
+
45
+ fastify.get('/api/variables/:key', async (request, reply) => {
46
+ if (!verifyToken(request)) {
47
+ return reply.code(401).send({ error: 'Unauthorized' })
48
+ }
49
+ const { key } = request.params
50
+ const value = variableStore.get('variables', key)
51
+ if (value === null) {
52
+ return reply.code(404).send({ error: `Variable not found: ${key}` })
53
+ }
54
+ return { success: true, key, value }
55
+ })
56
+
57
+ fastify.put('/api/variables/:key', async (request, reply) => {
58
+ if (!verifyToken(request)) {
59
+ return reply.code(401).send({ error: 'Unauthorized' })
60
+ }
61
+ const { key } = request.params
62
+ const { value } = request.body || {}
63
+
64
+ if (!isValidKey(key)) {
65
+ return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
66
+ }
67
+ if (!isValidValue(value)) {
68
+ return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
69
+ }
70
+
71
+ variableStore.set('variables', key, value)
72
+ return { success: true, key, value }
73
+ })
74
+
75
+ fastify.delete('/api/variables/:key', async (request, reply) => {
76
+ if (!verifyToken(request)) {
77
+ return reply.code(401).send({ error: 'Unauthorized' })
78
+ }
79
+ const { key } = request.params
80
+ const removed = variableStore.remove('variables', key)
81
+ if (!removed) {
82
+ return reply.code(404).send({ error: `Variable not found: ${key}` })
83
+ }
84
+ return { success: true, key }
85
+ })
86
+
87
+ // ─── Secrets (sensitive) ─────────────────────────────────────────────
88
+
89
+ fastify.get('/api/secrets', async (request, reply) => {
90
+ if (!verifyToken(request)) {
91
+ return reply.code(401).send({ error: 'Unauthorized' })
92
+ }
93
+ // Return keys only — never expose secret values via API
94
+ const keys = variableStore.listKeys('secrets')
95
+ return { success: true, keys }
96
+ })
97
+
98
+ fastify.put('/api/secrets/:key', async (request, reply) => {
99
+ if (!verifyToken(request)) {
100
+ return reply.code(401).send({ error: 'Unauthorized' })
101
+ }
102
+ const { key } = request.params
103
+ const { value } = request.body || {}
104
+
105
+ if (!isValidKey(key)) {
106
+ return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
107
+ }
108
+ if (!isValidValue(value)) {
109
+ return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
110
+ }
111
+
112
+ variableStore.set('secrets', key, value)
113
+ return { success: true, key }
114
+ })
115
+
116
+ fastify.delete('/api/secrets/:key', async (request, reply) => {
117
+ if (!verifyToken(request)) {
118
+ return reply.code(401).send({ error: 'Unauthorized' })
119
+ }
120
+ const { key } = request.params
121
+ const removed = variableStore.remove('secrets', key)
122
+ if (!removed) {
123
+ return reply.code(404).send({ error: `Secret not found: ${key}` })
124
+ }
125
+ return { success: true, key }
126
+ })
127
+
128
+ done()
129
+ }
130
+
131
+ module.exports = { variableRoutes }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Variable Store
3
+ *
4
+ * Manages minion-local secrets and variables stored in .env-style files.
5
+ * - Secrets: ~/.minion/.env.secrets (or DATA_DIR/.env.secrets)
6
+ * - Variables: ~/.minion/.env.variables (or DATA_DIR/.env.variables)
7
+ *
8
+ * Files use standard .env format: KEY=value (one per line, # for comments).
9
+ * Secrets never leave the minion; variables are non-sensitive configuration.
10
+ */
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+ const { DATA_DIR } = require('../lib/platform')
15
+ const { config } = require('../config')
16
+
17
+ /**
18
+ * Resolve file path for a given store type.
19
+ * @param {'secrets' | 'variables'} type
20
+ * @returns {string}
21
+ */
22
+ function getFilePath(type) {
23
+ const filename = type === 'secrets' ? '.env.secrets' : '.env.variables'
24
+ try {
25
+ fs.accessSync(DATA_DIR, fs.constants.W_OK)
26
+ return path.join(DATA_DIR, filename)
27
+ } catch {
28
+ return path.join(config.HOME_DIR, '.minion', filename)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Parse a .env file into a key-value object.
34
+ * @param {string} filePath
35
+ * @returns {Record<string, string>}
36
+ */
37
+ function parseEnvFile(filePath) {
38
+ const result = {}
39
+ try {
40
+ const content = fs.readFileSync(filePath, 'utf-8')
41
+ for (const line of content.split('\n')) {
42
+ const trimmed = line.trim()
43
+ if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue
44
+ const eqIdx = trimmed.indexOf('=')
45
+ const key = trimmed.slice(0, eqIdx).trim()
46
+ const value = trimmed.slice(eqIdx + 1).trim()
47
+ if (key) result[key] = value
48
+ }
49
+ } catch (err) {
50
+ if (err.code !== 'ENOENT') {
51
+ console.error(`[VariableStore] Failed to read ${filePath}: ${err.message}`)
52
+ }
53
+ }
54
+ return result
55
+ }
56
+
57
+ /**
58
+ * Write a key-value object to a .env file.
59
+ * @param {string} filePath
60
+ * @param {Record<string, string>} data
61
+ */
62
+ function writeEnvFile(filePath, data) {
63
+ const dir = path.dirname(filePath)
64
+ fs.mkdirSync(dir, { recursive: true })
65
+
66
+ const lines = Object.entries(data).map(([key, value]) => `${key}=${value}`)
67
+ fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
68
+ }
69
+
70
+ /**
71
+ * Get all key-value pairs for a store type.
72
+ * @param {'secrets' | 'variables'} type
73
+ * @returns {Record<string, string>}
74
+ */
75
+ function getAll(type) {
76
+ return parseEnvFile(getFilePath(type))
77
+ }
78
+
79
+ /**
80
+ * Get a single value by key.
81
+ * @param {'secrets' | 'variables'} type
82
+ * @param {string} key
83
+ * @returns {string | null}
84
+ */
85
+ function get(type, key) {
86
+ const data = getAll(type)
87
+ return data[key] ?? null
88
+ }
89
+
90
+ /**
91
+ * Set a key-value pair (creates or updates).
92
+ * @param {'secrets' | 'variables'} type
93
+ * @param {string} key
94
+ * @param {string} value
95
+ */
96
+ function set(type, key, value) {
97
+ const filePath = getFilePath(type)
98
+ const data = parseEnvFile(filePath)
99
+ data[key] = value
100
+ writeEnvFile(filePath, data)
101
+ console.log(`[VariableStore] Set ${type} key: ${key}`)
102
+ }
103
+
104
+ /**
105
+ * Remove a key.
106
+ * @param {'secrets' | 'variables'} type
107
+ * @param {string} key
108
+ * @returns {boolean} true if key existed
109
+ */
110
+ function remove(type, key) {
111
+ const filePath = getFilePath(type)
112
+ const data = parseEnvFile(filePath)
113
+ if (!(key in data)) return false
114
+ delete data[key]
115
+ writeEnvFile(filePath, data)
116
+ console.log(`[VariableStore] Removed ${type} key: ${key}`)
117
+ return true
118
+ }
119
+
120
+ /**
121
+ * List all keys for a store type.
122
+ * @param {'secrets' | 'variables'} type
123
+ * @returns {string[]}
124
+ */
125
+ function listKeys(type) {
126
+ return Object.keys(getAll(type))
127
+ }
128
+
129
+ /**
130
+ * Build a merged environment object from minion variables and secrets.
131
+ * Used by workflow/routine runners to inject into execution environment.
132
+ * Secrets override variables when keys collide.
133
+ *
134
+ * @param {Record<string, string>} [extraVars] - Additional variables (e.g. project/workflow vars from HQ)
135
+ * @returns {Record<string, string>} Merged key-value pairs
136
+ */
137
+ function buildEnv(extraVars = {}) {
138
+ const variables = getAll('variables')
139
+ const secrets = getAll('secrets')
140
+ // Merge order: variables < secrets < extraVars (later wins)
141
+ return { ...variables, ...secrets, ...extraVars }
142
+ }
143
+
144
+ module.exports = {
145
+ getAll,
146
+ get,
147
+ set,
148
+ remove,
149
+ listKeys,
150
+ buildEnv,
151
+ getFilePath,
152
+ }
@@ -285,7 +285,8 @@ function streamLlmResponse(res, prompt, sessionId) {
285
285
  args.push('--resume', sessionId)
286
286
  }
287
287
 
288
- args.push(prompt)
288
+ // Prompt is passed via stdin (not as CLI argument) to avoid
289
+ // shell argument parsing issues with spaces/special characters.
289
290
 
290
291
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
291
292
 
@@ -304,7 +305,8 @@ function streamLlmResponse(res, prompt, sessionId) {
304
305
  // Track active child process for abort
305
306
  activeChatChild = child
306
307
 
307
- // Close stdin immediately so CLI doesn't wait for input
308
+ // Write prompt to stdin and close claude -p reads from stdin when no positional arg
309
+ child.stdin.write(prompt)
308
310
  child.stdin.end()
309
311
 
310
312
  console.log(`[Chat] child PID: ${child.pid}`)
@@ -509,7 +511,7 @@ function runQuickLlmCall(prompt) {
509
511
  '/bin',
510
512
  ].join(':')
511
513
 
512
- const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json', prompt]
514
+ const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json']
513
515
 
514
516
  const child = spawn(binary, args, {
515
517
  cwd: config.HOME_DIR,
@@ -523,6 +525,7 @@ function runQuickLlmCall(prompt) {
523
525
  },
524
526
  })
525
527
 
528
+ child.stdin.write(prompt)
526
529
  child.stdin.end()
527
530
 
528
531
  let stdout = ''
@@ -22,6 +22,7 @@ const { config } = require('../core/config')
22
22
  const executionStore = require('../core/stores/execution-store')
23
23
  const routineStore = require('../core/stores/routine-store')
24
24
  const logManager = require('../core/lib/log-manager')
25
+ const variableStore = require('../core/stores/variable-store')
25
26
 
26
27
  // Active cron jobs keyed by routine ID
27
28
  const activeJobs = new Map()
@@ -155,8 +156,13 @@ async function executeRoutineSession(routine, executionId, skillNames) {
155
156
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
156
157
  const execCommand = `${llmCommand}; echo $? > ${exitCodeFile}`
157
158
 
159
+ // Build injected environment: minion variables/secrets (routines don't receive HQ vars)
160
+ const injectedEnv = variableStore.buildEnv()
161
+
158
162
  // Create tmux session with extended environment
159
163
  // Pass execution context as environment variables for /execution-report skill
164
+ const tmuxEnvFlags = Object.entries(injectedEnv)
165
+ .map(([k, v]) => `-e "${k}=${v.replace(/"/g, '\\"')}"`)
160
166
  const tmuxCommand = [
161
167
  'tmux new-session -d',
162
168
  `-s "${sessionName}"`,
@@ -167,6 +173,7 @@ async function executeRoutineSession(routine, executionId, skillNames) {
167
173
  `-e "MINION_EXECUTION_ID=${executionId}"`,
168
174
  `-e "MINION_ROUTINE_ID=${routine.id}"`,
169
175
  `-e "MINION_ROUTINE_NAME=${routine.name.replace(/"/g, '\\"')}"`,
176
+ ...tmuxEnvFlags,
170
177
  `"${execCommand}"`,
171
178
  ].join(' ')
172
179
 
package/linux/server.js CHANGED
@@ -16,6 +16,8 @@
16
16
  * Directives: POST /api/directive
17
17
  * Auth: GET /api/auth/status
18
18
  * Chat: POST /api/chat, GET /api/chat/session, POST /api/chat/clear
19
+ * Variables: GET/PUT/DELETE /api/variables, /api/variables/:key
20
+ * Secrets: GET /api/secrets, PUT/DELETE /api/secrets/:key
19
21
  * Config: GET /api/config/backup, GET/PUT /api/config/env
20
22
  * Executions: GET /api/executions, GET /api/executions/:id, etc.
21
23
  * ─────────────────────────────────────────────────────────────────────────────
@@ -33,6 +35,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..')
33
35
  const { config, validate, isHqConfigured } = require('../core/config')
34
36
  const { sendHeartbeat } = require('../core/api')
35
37
  const { version } = require('../package.json')
38
+ const { getCapabilities } = require('../core/lib/capability-checker')
36
39
  const workflowStore = require('../core/stores/workflow-store')
37
40
  const routineStore = require('../core/stores/routine-store')
38
41
 
@@ -57,6 +60,7 @@ const { skillRoutes } = require('../core/routes/skills')
57
60
  const { workflowRoutes } = require('../core/routes/workflows')
58
61
  const { routineRoutes } = require('../core/routes/routines')
59
62
  const { authRoutes } = require('../core/routes/auth')
63
+ const { variableRoutes } = require('../core/routes/variables')
60
64
 
61
65
  // Linux-specific routes
62
66
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
@@ -89,7 +93,7 @@ async function shutdown(signal) {
89
93
  if (isHqConfigured()) {
90
94
  try {
91
95
  await Promise.race([
92
- sendHeartbeat({ status: 'offline', version }),
96
+ sendHeartbeat({ status: 'offline', version, capabilities: getCapabilities() }),
93
97
  new Promise(resolve => setTimeout(resolve, 3000)),
94
98
  ])
95
99
  } catch {
@@ -252,6 +256,7 @@ async function registerAllRoutes(app) {
252
256
  await app.register(workflowRoutes, { workflowRunner })
253
257
  await app.register(routineRoutes, { routineRunner })
254
258
  await app.register(authRoutes)
259
+ await app.register(variableRoutes)
255
260
 
256
261
  // Linux-specific routes
257
262
  await app.register(commandRoutes)
@@ -317,14 +322,14 @@ async function start() {
317
322
  // Send initial online heartbeat
318
323
  const { getStatus } = require('../core/routes/health')
319
324
  const { currentTask } = getStatus()
320
- sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
325
+ sendHeartbeat({ status: 'online', current_task: currentTask, version, capabilities: getCapabilities() }).catch(err => {
321
326
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
322
327
  })
323
328
 
324
329
  // Start periodic heartbeat
325
330
  heartbeatTimer = setInterval(() => {
326
331
  const { currentStatus, currentTask } = getStatus()
327
- sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
332
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, version, capabilities: getCapabilities() }).catch(err => {
328
333
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
329
334
  })
330
335
  }, HEARTBEAT_INTERVAL_MS)
@@ -20,6 +20,7 @@ const execAsync = promisify(exec)
20
20
  const { config } = require('../core/config')
21
21
  const executionStore = require('../core/stores/execution-store')
22
22
  const workflowStore = require('../core/stores/workflow-store')
23
+ const variableStore = require('../core/stores/variable-store')
23
24
  const logManager = require('../core/lib/log-manager')
24
25
 
25
26
  // Active cron jobs keyed by workflow ID
@@ -117,7 +118,12 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
117
118
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
118
119
  const execCommand = `${llmCommand}; echo $? > ${exitCodeFile}`
119
120
 
121
+ // Build injected environment: minion variables/secrets + extra vars from HQ
122
+ const injectedEnv = variableStore.buildEnv(options.extraEnv || {})
123
+
120
124
  // Create tmux session with extended environment
125
+ const tmuxEnvFlags = Object.entries(injectedEnv)
126
+ .map(([k, v]) => `-e "${k}=${v.replace(/"/g, '\\"')}"`)
121
127
  const tmuxCommand = [
122
128
  'tmux new-session -d',
123
129
  `-s "${sessionName}"`,
@@ -125,6 +131,7 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
125
131
  `-e "DISPLAY=:99"`,
126
132
  `-e "PATH=${extendedPath}"`,
127
133
  `-e "HOME=${homeDir}"`,
134
+ ...tmuxEnvFlags,
128
135
  `"${execCommand}"`,
129
136
  ].join(' ')
130
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.44.0",
3
+ "version": "2.46.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -218,7 +218,8 @@ function streamLlmResponse(res, prompt, sessionId) {
218
218
 
219
219
  const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
220
220
  if (sessionId) args.push('--resume', sessionId)
221
- args.push(prompt)
221
+ // Prompt is passed via stdin (not as CLI argument) to avoid
222
+ // shell argument parsing issues with spaces/special characters.
222
223
 
223
224
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'}`)
224
225
 
@@ -236,6 +237,8 @@ function streamLlmResponse(res, prompt, sessionId) {
236
237
  })
237
238
 
238
239
  activeChatChild = child
240
+ // Write prompt to stdin and close — claude -p reads from stdin when no positional arg
241
+ child.stdin.write(prompt)
239
242
  child.stdin.end()
240
243
 
241
244
  console.log(`[Chat] child PID: ${child.pid}`)
@@ -401,7 +404,7 @@ function runQuickLlmCall(prompt) {
401
404
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
402
405
  const extendedPath = buildExtendedPath(config.HOME_DIR)
403
406
 
404
- const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json', prompt]
407
+ const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json']
405
408
 
406
409
  const child = spawn(binary, args, {
407
410
  cwd: config.HOME_DIR,
@@ -416,6 +419,7 @@ function runQuickLlmCall(prompt) {
416
419
  },
417
420
  })
418
421
 
422
+ child.stdin.write(prompt)
419
423
  child.stdin.end()
420
424
 
421
425
  let stdout = ''
@@ -17,6 +17,7 @@ const executionStore = require('../core/stores/execution-store')
17
17
  const routineStore = require('../core/stores/routine-store')
18
18
  const logManager = require('../core/lib/log-manager')
19
19
  const { MARKER_DIR, buildExtendedPath } = require('../core/lib/platform')
20
+ const variableStore = require('../core/stores/variable-store')
20
21
  const { activeSessions } = require('./workflow-runner')
21
22
 
22
23
  const activeJobs = new Map()
@@ -105,8 +106,11 @@ async function executeRoutineSession(routine, executionId, skillNames) {
105
106
  const escapedPrompt = prompt.replace(/'/g, "''")
106
107
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
107
108
 
109
+ // Inject minion variables/secrets (routines don't receive HQ vars)
110
+ const injectedEnv = variableStore.buildEnv()
108
111
  const env = {
109
112
  ...process.env,
113
+ ...injectedEnv,
110
114
  HOME: homeDir,
111
115
  USERPROFILE: homeDir,
112
116
  PATH: extendedPath,
package/win/server.js CHANGED
@@ -16,6 +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
+ const { getCapabilities } = require('../core/lib/capability-checker')
19
20
  const workflowStore = require('../core/stores/workflow-store')
20
21
  const routineStore = require('../core/stores/routine-store')
21
22
 
@@ -45,6 +46,7 @@ const { skillRoutes } = require('../core/routes/skills')
45
46
  const { workflowRoutes } = require('../core/routes/workflows')
46
47
  const { routineRoutes } = require('../core/routes/routines')
47
48
  const { authRoutes } = require('../core/routes/auth')
49
+ const { variableRoutes } = require('../core/routes/variables')
48
50
 
49
51
  // Validate configuration
50
52
  validate()
@@ -69,7 +71,7 @@ async function shutdown(signal) {
69
71
  if (isHqConfigured()) {
70
72
  try {
71
73
  await Promise.race([
72
- sendHeartbeat({ status: 'offline', version }),
74
+ sendHeartbeat({ status: 'offline', version, capabilities: getCapabilities() }),
73
75
  new Promise(resolve => setTimeout(resolve, 3000)),
74
76
  ])
75
77
  } catch {
@@ -192,6 +194,7 @@ async function registerRoutes(app) {
192
194
  await app.register(workflowRoutes, { workflowRunner })
193
195
  await app.register(routineRoutes, { routineRunner })
194
196
  await app.register(authRoutes)
197
+ await app.register(variableRoutes)
195
198
 
196
199
  // Windows-specific routes
197
200
  await app.register(commandRoutes)
@@ -255,14 +258,14 @@ async function start() {
255
258
  // Send initial online heartbeat
256
259
  const { getStatus } = require('../core/routes/health')
257
260
  const { currentTask } = getStatus()
258
- sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
261
+ sendHeartbeat({ status: 'online', current_task: currentTask, version, capabilities: getCapabilities() }).catch(err => {
259
262
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
260
263
  })
261
264
 
262
265
  // Start periodic heartbeat
263
266
  heartbeatTimer = setInterval(() => {
264
267
  const { currentStatus, currentTask } = getStatus()
265
- sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
268
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, version, capabilities: getCapabilities() }).catch(err => {
266
269
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
267
270
  })
268
271
  }, HEARTBEAT_INTERVAL_MS)
@@ -24,6 +24,7 @@ const executionStore = require('../core/stores/execution-store')
24
24
  const workflowStore = require('../core/stores/workflow-store')
25
25
  const logManager = require('../core/lib/log-manager')
26
26
  const { buildExtendedPath } = require('../core/lib/platform')
27
+ const variableStore = require('../core/stores/variable-store')
27
28
 
28
29
  // Active cron jobs keyed by workflow ID
29
30
  const activeJobs = new Map()
@@ -112,9 +113,11 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
112
113
  const escapedPrompt = prompt.replace(/'/g, "''")
113
114
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
114
115
 
115
- // Build environment
116
+ // Build environment: base + minion variables/secrets + extra vars from HQ
117
+ const injectedEnv = variableStore.buildEnv(options.extraEnv || {})
116
118
  const env = {
117
119
  ...process.env,
120
+ ...injectedEnv,
118
121
  HOME: homeDir,
119
122
  USERPROFILE: homeDir,
120
123
  PATH: extendedPath,