@geekbeer/minion 2.43.3 → 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.43.3",
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,8 +218,8 @@ function Restart-MinionProcess {
218
218
  # ============================================================
219
219
 
220
220
  function Invoke-Setup {
221
- $totalSteps = 9
222
- if ($SetupTunnel) { $totalSteps = 10 }
221
+ $totalSteps = 10
222
+ if ($SetupTunnel) { $totalSteps = 11 }
223
223
 
224
224
  # Minionization warning
225
225
  Write-Host ""
@@ -308,8 +308,64 @@ function Invoke-Setup {
308
308
  }
309
309
  }
310
310
 
311
- # Step 2: Install Claude Code CLI
312
- Write-Step 2 $totalSteps "Installing Claude Code..."
311
+ # Step 2: Install Git (required by Claude Code for git-bash)
312
+ Write-Step 2 $totalSteps "Checking Git (git-bash)..."
313
+ $gitBashPath = $null
314
+ if (Test-CommandExists 'git') {
315
+ $gitVersion = & git --version 2>$null
316
+ Write-Detail "Git already installed ($gitVersion)"
317
+ # Detect bash.exe path from git installation
318
+ $gitExe = (Get-Command git).Source
319
+ $gitDir = Split-Path (Split-Path $gitExe)
320
+ $candidateBash = Join-Path $gitDir 'bin\bash.exe'
321
+ if (Test-Path $candidateBash) { $gitBashPath = $candidateBash }
322
+ }
323
+ else {
324
+ Write-Host " Installing Git via winget..."
325
+ try {
326
+ & winget install --id Git.Git --accept-package-agreements --accept-source-agreements
327
+ # Refresh PATH
328
+ $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
329
+ if (Test-CommandExists 'git') {
330
+ Write-Detail "Git installed successfully"
331
+ $gitExe = (Get-Command git).Source
332
+ $gitDir = Split-Path (Split-Path $gitExe)
333
+ $candidateBash = Join-Path $gitDir 'bin\bash.exe'
334
+ if (Test-Path $candidateBash) { $gitBashPath = $candidateBash }
335
+ }
336
+ else {
337
+ Write-Warn "Git installed but not in PATH. Please restart this terminal."
338
+ }
339
+ }
340
+ catch {
341
+ Write-Warn "Failed to install Git. Please install manually from https://git-scm.com/downloads/win"
342
+ }
343
+ }
344
+ # Fallback: search common install paths
345
+ if (-not $gitBashPath) {
346
+ $commonPaths = @(
347
+ 'C:\Program Files\Git\bin\bash.exe',
348
+ 'C:\Program Files (x86)\Git\bin\bash.exe',
349
+ (Join-Path $env:LOCALAPPDATA 'Programs\Git\bin\bash.exe')
350
+ )
351
+ foreach ($p in $commonPaths) {
352
+ if (Test-Path $p) { $gitBashPath = $p; break }
353
+ }
354
+ }
355
+ if ($gitBashPath) {
356
+ Write-Detail "git-bash found: $gitBashPath"
357
+ # Set CLAUDE_CODE_GIT_BASH_PATH for the current user (persists across sessions)
358
+ [Environment]::SetEnvironmentVariable('CLAUDE_CODE_GIT_BASH_PATH', $gitBashPath, 'User')
359
+ $env:CLAUDE_CODE_GIT_BASH_PATH = $gitBashPath
360
+ Write-Detail "CLAUDE_CODE_GIT_BASH_PATH set for current user"
361
+ }
362
+ else {
363
+ Write-Warn "git-bash not found. Claude Code may not work."
364
+ Write-Host " Install Git from https://git-scm.com/downloads/win and re-run setup." -ForegroundColor Gray
365
+ }
366
+
367
+ # Step 3: Install Claude Code CLI
368
+ Write-Step 3 $totalSteps "Installing Claude Code..."
313
369
  if (Test-CommandExists 'claude') {
314
370
  $claudeVersion = & claude --version 2>$null
315
371
  Write-Detail "Claude Code already installed ($claudeVersion)"
@@ -330,8 +386,8 @@ function Invoke-Setup {
330
386
  Write-Host " Please run 'claude' in a terminal to complete the authentication process." -ForegroundColor Yellow
331
387
  Write-Host ""
332
388
 
333
- # Step 3: Create config directory and .env
334
- Write-Step 3 $totalSteps "Creating config directory and .env..."
389
+ # Step 4: Create config directory and .env
390
+ Write-Step 4 $totalSteps "Creating config directory and .env..."
335
391
  New-Item -Path $DataDir -ItemType Directory -Force | Out-Null
336
392
  New-Item -Path $LogDir -ItemType Directory -Force | Out-Null
337
393
  $envValues = @{
@@ -344,8 +400,8 @@ function Invoke-Setup {
344
400
  Write-EnvFile $EnvFile $envValues
345
401
  Write-Detail "$EnvFile generated"
346
402
 
347
- # Step 4: Install node-pty (required for Windows terminal management)
348
- Write-Step 4 $totalSteps "Installing terminal support (node-pty)..."
403
+ # Step 5: Install node-pty (required for Windows terminal management)
404
+ Write-Step 5 $totalSteps "Installing terminal support (node-pty)..."
349
405
  $npmRoot = & npm root -g 2>$null
350
406
  $minionPkgDir = Join-Path $npmRoot '@geekbeer\minion'
351
407
  if (Test-Path $minionPkgDir) {
@@ -383,8 +439,8 @@ function Invoke-Setup {
383
439
  Write-Host " Please run: npm install -g @geekbeer/minion"
384
440
  }
385
441
 
386
- # Step 5: Generate start-agent.ps1 and register auto-start
387
- Write-Step 5 $totalSteps "Registering auto-start..."
442
+ # Step 6: Generate start-agent.ps1 and register auto-start
443
+ Write-Step 6 $totalSteps "Registering auto-start..."
388
444
  $serverJs = Join-Path $minionPkgDir 'win\server.js'
389
445
  if (-not (Test-Path $serverJs)) {
390
446
  $serverJs = Join-Path $minionPkgDir 'win' 'server.js'
@@ -560,8 +616,8 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
560
616
  $shortcut.Save()
561
617
  Write-Detail "Startup shortcut created: $shortcutPath"
562
618
 
563
- # Step 6: Disable screensaver, lock screen, and sleep
564
- Write-Step 6 $totalSteps "Disabling screensaver, lock screen, and sleep..."
619
+ # Step 7: Disable screensaver, lock screen, and sleep
620
+ Write-Step 7 $totalSteps "Disabling screensaver, lock screen, and sleep..."
565
621
  # Screensaver off
566
622
  Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
567
623
  Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
@@ -577,8 +633,8 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
577
633
  & powercfg -change -monitor-timeout-dc 0 2>$null
578
634
  Write-Detail "Sleep and monitor timeout disabled"
579
635
 
580
- # Step 7: Install TightVNC Server
581
- Write-Step 7 $totalSteps "Setting up TightVNC Server..."
636
+ # Step 8: Install TightVNC Server
637
+ Write-Step 8 $totalSteps "Setting up TightVNC Server..."
582
638
  $vncSystemPath = 'C:\Program Files\TightVNC\tvnserver.exe'
583
639
  $vncPortableDir = Join-Path $DataDir 'tightvnc'
584
640
  $vncPortablePath = Join-Path $vncPortableDir 'PFiles\TightVNC\tvnserver.exe'
@@ -637,8 +693,8 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
637
693
  Write-Detail "TightVNC configured: localhost-only, no VNC password (auth via HQ proxy)"
638
694
  }
639
695
 
640
- # Step 8: Setup websockify (WebSocket proxy for VNC)
641
- Write-Step 8 $totalSteps "Setting up websockify..."
696
+ # Step 9: Setup websockify (WebSocket proxy for VNC)
697
+ Write-Step 9 $totalSteps "Setting up websockify..."
642
698
  if (Get-WebsockifyCommand) {
643
699
  Write-Detail "websockify already installed"
644
700
  }
@@ -694,7 +750,7 @@ Remove-Item `$PidFile -Force -ErrorAction SilentlyContinue
694
750
  }
695
751
  }
696
752
 
697
- # Step 9 (optional): Cloudflare Tunnel
753
+ # Step 10 (optional): Cloudflare Tunnel
698
754
  if ($SetupTunnel) {
699
755
  $currentStep = $totalSteps - 1
700
756
  Write-Step $currentStep $totalSteps "Setting up Cloudflare Tunnel..."
@@ -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,