@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.
- package/core/lib/capability-checker.js +105 -0
- package/core/lib/step-poller.js +4 -0
- package/core/routes/health.js +2 -0
- package/core/routes/skills.js +2 -1
- package/core/routes/variables.js +131 -0
- package/core/stores/variable-store.js +152 -0
- package/linux/routes/chat.js +6 -3
- package/linux/routine-runner.js +7 -0
- package/linux/server.js +8 -3
- package/linux/workflow-runner.js +7 -0
- package/package.json +1 -1
- package/win/routes/chat.js +6 -2
- package/win/routine-runner.js +4 -0
- package/win/server.js +6 -3
- package/win/workflow-runner.js +4 -1
|
@@ -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 }
|
package/core/lib/step-poller.js
CHANGED
|
@@ -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, {
|
package/core/routes/health.js
CHANGED
|
@@ -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 || '',
|
package/core/routes/skills.js
CHANGED
|
@@ -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
|
+
}
|
package/linux/routes/chat.js
CHANGED
|
@@ -285,7 +285,8 @@ function streamLlmResponse(res, prompt, sessionId) {
|
|
|
285
285
|
args.push('--resume', sessionId)
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
|
|
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
|
-
//
|
|
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'
|
|
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 = ''
|
package/linux/routine-runner.js
CHANGED
|
@@ -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)
|
package/linux/workflow-runner.js
CHANGED
|
@@ -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
package/win/routes/chat.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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 = ''
|
package/win/routine-runner.js
CHANGED
|
@@ -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)
|
package/win/workflow-runner.js
CHANGED
|
@@ -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,
|