@geekbeer/minion 2.54.1 → 2.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,6 @@ function resolveDataDir() {
31
31
 
32
32
  const DATA_DIR = resolveDataDir()
33
33
  const LOG_DIR = path.join(DATA_DIR, 'logs')
34
- const MARKER_DIR = path.join(TEMP_DIR, 'minion-executions')
35
34
 
36
35
  /**
37
36
  * Build extended PATH including common CLI installation locations.
@@ -104,7 +103,6 @@ module.exports = {
104
103
  TEMP_DIR,
105
104
  DATA_DIR,
106
105
  LOG_DIR,
107
- MARKER_DIR,
108
106
  buildExtendedPath,
109
107
  getExitCodePath,
110
108
  getDefaultShell,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Running Tasks Store
3
+ *
4
+ * In-memory store that tracks what tmux sessions are currently running
5
+ * on this minion. Reported to HQ via heartbeat so the dashboard can
6
+ * show the minion's actual state (the "body" of the minion).
7
+ *
8
+ * Mutations trigger an immediate heartbeat (debounced to 2s) so the
9
+ * dashboard receives near-real-time updates.
10
+ */
11
+
12
+ const { isHqConfigured } = require('../config')
13
+
14
+ /** @type {Array<import('../../src/lib/supabase/types').RunningTask>} */
15
+ let tasks = []
16
+
17
+ // Debounce timer for immediate heartbeat on mutation
18
+ let debounceTimer = null
19
+ const DEBOUNCE_MS = 2000
20
+
21
+ // Lazy reference — resolved on first use to avoid circular require
22
+ let _sendHeartbeat = null
23
+ let _getStatus = null
24
+ let _version = null
25
+
26
+ function getSendHeartbeat() {
27
+ if (!_sendHeartbeat) {
28
+ _sendHeartbeat = require('../api').sendHeartbeat
29
+ }
30
+ return _sendHeartbeat
31
+ }
32
+
33
+ function getStatusFn() {
34
+ if (!_getStatus) {
35
+ _getStatus = require('../routes/health').getStatus
36
+ }
37
+ return _getStatus
38
+ }
39
+
40
+ function getVersion() {
41
+ if (!_version) {
42
+ _version = require('../../package.json').version
43
+ }
44
+ return _version
45
+ }
46
+
47
+ /**
48
+ * Push a status change to HQ immediately (debounced).
49
+ */
50
+ function pushToHQ() {
51
+ if (!isHqConfigured()) return
52
+
53
+ if (debounceTimer) clearTimeout(debounceTimer)
54
+ debounceTimer = setTimeout(() => {
55
+ debounceTimer = null
56
+ try {
57
+ const { currentStatus, currentTask } = getStatusFn()
58
+ getSendHeartbeat()({
59
+ status: currentStatus,
60
+ current_task: currentTask,
61
+ running_tasks: tasks,
62
+ version: getVersion(),
63
+ }).catch(err => {
64
+ console.error('[RunningTasks] Heartbeat push failed:', err.message)
65
+ })
66
+ } catch (err) {
67
+ console.error('[RunningTasks] Failed to push heartbeat:', err.message)
68
+ }
69
+ }, DEBOUNCE_MS)
70
+ }
71
+
72
+ /**
73
+ * Add a running task.
74
+ * @param {object} entry
75
+ * @param {'workflow'|'routine'|'directive'} entry.type
76
+ * @param {string} [entry.workflow_execution_id]
77
+ * @param {number} [entry.step_index]
78
+ * @param {string} [entry.routine_id]
79
+ * @param {string} entry.session_name
80
+ * @param {string} [entry.started_at] - ISO timestamp, defaults to now
81
+ */
82
+ function add(entry) {
83
+ const task = {
84
+ type: entry.type,
85
+ session_name: entry.session_name,
86
+ started_at: entry.started_at || new Date().toISOString(),
87
+ }
88
+ if (entry.workflow_execution_id) task.workflow_execution_id = entry.workflow_execution_id
89
+ if (entry.step_index != null) task.step_index = entry.step_index
90
+ if (entry.routine_id) task.routine_id = entry.routine_id
91
+
92
+ tasks.push(task)
93
+ console.log(`[RunningTasks] Added: ${task.type} session=${task.session_name}`)
94
+ pushToHQ()
95
+ }
96
+
97
+ /**
98
+ * Remove a running task by session name.
99
+ * @param {string} sessionName
100
+ */
101
+ function remove(sessionName) {
102
+ const before = tasks.length
103
+ tasks = tasks.filter(t => t.session_name !== sessionName)
104
+ if (tasks.length < before) {
105
+ console.log(`[RunningTasks] Removed: session=${sessionName}`)
106
+ pushToHQ()
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get all currently running tasks.
112
+ * @returns {Array}
113
+ */
114
+ function getAll() {
115
+ return [...tasks]
116
+ }
117
+
118
+ /**
119
+ * Clear all tasks (used on shutdown).
120
+ */
121
+ function clear() {
122
+ tasks = []
123
+ }
124
+
125
+ module.exports = { add, remove, getAll, clear }
@@ -94,7 +94,7 @@ async function executeStep(step) {
94
94
  assigned_role,
95
95
  skill_name,
96
96
  revision_feedback,
97
- extra_env,
97
+ template_vars,
98
98
  } = step
99
99
 
100
100
  console.log(
@@ -125,9 +125,13 @@ async function executeStep(step) {
125
125
  }
126
126
 
127
127
  // 2. Fetch the skill from HQ to ensure it's deployed locally
128
+ // Pass template_vars as ?vars= so HQ expands {{VAR_NAME}} in SKILL.md
128
129
  if (skill_name) {
129
130
  try {
130
- const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}`
131
+ const varsParam = template_vars
132
+ ? `?vars=${Buffer.from(JSON.stringify(template_vars)).toString('base64')}`
133
+ : ''
134
+ const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skill_name)}${varsParam}`
131
135
  const fetchResp = await fetch(fetchUrl, {
132
136
  method: 'POST',
133
137
  headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
@@ -152,9 +156,6 @@ async function executeStep(step) {
152
156
  if (revision_feedback) {
153
157
  runPayload.revision_feedback = revision_feedback
154
158
  }
155
- if (extra_env && typeof extra_env === 'object') {
156
- runPayload.extra_env = extra_env
157
- }
158
159
 
159
160
  const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
160
161
  const runResp = await fetch(runUrl, {
@@ -14,6 +14,7 @@ const { config, isHqConfigured } = require('../config')
14
14
  const { sendHeartbeat } = require('../api')
15
15
  const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
16
16
  const { getCapabilities, checkRequirements } = require('../lib/capability-checker')
17
+ const runningTasks = require('../lib/running-tasks')
17
18
 
18
19
  function maskToken(token) {
19
20
  if (!token || token.length < 8) return token ? '***' : ''
@@ -99,7 +100,7 @@ async function healthRoutes(fastify) {
99
100
 
100
101
  // Push status change to HQ immediately via heartbeat
101
102
  if (changed && isHqConfigured()) {
102
- sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
103
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), version }).catch(err => {
103
104
  console.error('[Heartbeat] Status-change heartbeat failed:', err.message)
104
105
  })
105
106
  }
@@ -248,11 +248,14 @@ async function skillRoutes(fastify, opts) {
248
248
  return { success: false, error: 'Invalid skill name' }
249
249
  }
250
250
 
251
- console.log(`[Skills] Fetching skill from HQ: ${name}`)
251
+ // Forward template vars query param to HQ for {{VAR_NAME}} expansion
252
+ const vars = request.query.vars || ''
253
+ const queryString = vars ? `?vars=${encodeURIComponent(vars)}` : ''
254
+ console.log(`[Skills] Fetching skill from HQ: ${name}${vars ? ' (with template vars)' : ''}`)
252
255
 
253
256
  try {
254
- // Fetch from HQ
255
- const skill = await api.request(`/skills/${encodeURIComponent(name)}`)
257
+ // Fetch from HQ (with optional template variable expansion)
258
+ const skill = await api.request(`/skills/${encodeURIComponent(name)}${queryString}`)
256
259
 
257
260
  // Write to local filesystem using shared helper
258
261
  const result = await writeSkillToLocal(name, {
@@ -308,7 +311,7 @@ async function skillRoutes(fastify, opts) {
308
311
  return { success: false, error: 'Unauthorized' }
309
312
  }
310
313
 
311
- const { skill_name, execution_id, step_index, workflow_name, role, revision_feedback, extra_env } = request.body || {}
314
+ const { skill_name, execution_id, step_index, workflow_name, role, revision_feedback } = request.body || {}
312
315
 
313
316
  if (!skill_name) {
314
317
  reply.code(400)
@@ -357,7 +360,6 @@ async function skillRoutes(fastify, opts) {
357
360
  const runOptions = {}
358
361
  if (role) runOptions.role = role
359
362
  if (revision_feedback) runOptions.revisionFeedback = revision_feedback
360
- if (extra_env && typeof extra_env === 'object') runOptions.extraEnv = extra_env
361
363
 
362
364
  // Run asynchronously — respond immediately
363
365
  const executionPromise = (async () => {
@@ -98,6 +98,8 @@ function set(type, key, value) {
98
98
  const data = parseEnvFile(filePath)
99
99
  data[key] = value
100
100
  writeEnvFile(filePath, data)
101
+ // Sync to process.env so running child processes inherit the updated value
102
+ process.env[key] = value
101
103
  console.log(`[VariableStore] Set ${type} key: ${key}`)
102
104
  }
103
105
 
@@ -113,6 +115,8 @@ function remove(type, key) {
113
115
  if (!(key in data)) return false
114
116
  delete data[key]
115
117
  writeEnvFile(filePath, data)
118
+ // Sync to process.env so running child processes no longer inherit the removed value
119
+ delete process.env[key]
116
120
  console.log(`[VariableStore] Removed ${type} key: ${key}`)
117
121
  return true
118
122
  }
@@ -278,7 +278,7 @@ Response:
278
278
  }
279
279
  ```
280
280
 
281
- プロジェクト変数はワークフロー実行時に `extra_env` としてミニオンに渡される。
281
+ プロジェクト変数はワークフロー実行時に `template_vars` としてミニオンに渡され、スキルの `{{VAR_NAME}}` テンプレートを展開する。
282
282
 
283
283
  ### Workflow Variables (PM only)
284
284
 
@@ -192,8 +192,11 @@ cat ~/.mcp.json | node -e "
192
192
  console.log('Configured MCP servers:', Object.keys(servers).join(', ') || '(none)');
193
193
  "
194
194
 
195
- # エージェントのキャパビリティキャッシュをクリアして再検出させる
196
- # (キャパビリティは 5 分間キャッシュされるため、即座に反映したい場合)
197
- curl -s -X POST "http://localhost:8080/api/capabilities/clear-cache" \
198
- -H "Authorization: Bearer $API_TOKEN"
195
+ # ケイパビリティが更新されているか確認(キャッシュなし、常に最新を返す)
196
+ curl -s "http://localhost:8080/api/capabilities" \
197
+ -H "Authorization: Bearer $API_TOKEN" | node -e "
198
+ const data = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
199
+ console.log('MCP servers:', Object.keys(data.mcp_servers || {}).join(', ') || '(none)');
200
+ console.log('Packages:', Object.keys(data.packages || {}).map(m => m + ':' + data.packages[m].items.length).join(', ') || '(none)');
201
+ "
199
202
  ```
@@ -30,6 +30,7 @@ requires:
30
30
  ---
31
31
 
32
32
  Skill instructions here...
33
+ Use {{PROJECT_VAR}} to reference project/workflow variables.
33
34
  ```
34
35
 
35
36
  フロントマターのフィールド:
@@ -46,6 +47,24 @@ Skill instructions here...
46
47
  `requires` を宣言すると、ワークフロー実行前にミニオンの環境と照合する事前チェック(readiness check)が行われる。
47
48
  未宣言の場合は常に実行可能と見なされる。
48
49
 
50
+ ### テンプレート変数
51
+
52
+ スキル本文中で `{{VAR_NAME}}` と記述すると、ワークフロー実行時にHQ上のプロジェクト変数・ワークフロー変数の値で自動的に置換される。スキルを再利用しつつ、プロジェクトごとに異なるパラメータを渡したい場合に使う。
53
+
54
+ ```markdown
55
+ ---
56
+ name: deploy-site
57
+ description: サイトをデプロイする
58
+ ---
59
+
60
+ {{DEPLOY_TARGET}} に {{SITE_URL}} をデプロイしてください。
61
+ ```
62
+
63
+ - 変数名は英数字とアンダースコアのみ(`\w+`)
64
+ - 未定義の変数は `{{VAR_NAME}}` のまま残る(エラーにはならない)
65
+ - ワークフロー変数はプロジェクト変数を上書きする(同名キーの場合)
66
+ - ミニオン変数・シークレットは `process.env` 経由で利用可能(テンプレートではなく環境変数として)
67
+
49
68
  ### 2. HQ に反映する
50
69
 
51
70
  ```bash
@@ -301,16 +301,6 @@ function streamLlmResponse(res, prompt, sessionId) {
301
301
  const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
302
302
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
303
303
 
304
- const extendedPath = [
305
- `${config.HOME_DIR}/bin`,
306
- `${config.HOME_DIR}/.npm-global/bin`,
307
- `${config.HOME_DIR}/.local/bin`,
308
- `${config.HOME_DIR}/.claude/bin`,
309
- '/usr/local/bin',
310
- '/usr/bin',
311
- '/bin',
312
- ].join(':')
313
-
314
304
  // Build CLI args (no --max-turns: allow unlimited turns for task completion)
315
305
  const args = [
316
306
  '-p',
@@ -329,16 +319,12 @@ function streamLlmResponse(res, prompt, sessionId) {
329
319
 
330
320
  console.log(`[Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new session)'} (cwd: ${config.HOME_DIR})`)
331
321
 
322
+ // PATH, HOME, DISPLAY, and minion variables/secrets are already set in
323
+ // process.env at server startup, so child processes inherit them automatically.
332
324
  const child = spawn(binary, args, {
333
325
  cwd: config.HOME_DIR,
334
326
  stdio: ['pipe', 'pipe', 'pipe'],
335
327
  timeout: 600000, // 10 min
336
- env: {
337
- ...process.env,
338
- HOME: config.HOME_DIR,
339
- PATH: extendedPath,
340
- DISPLAY: ':99',
341
- },
342
328
  })
343
329
 
344
330
  // Track active child process for abort
@@ -540,28 +526,12 @@ function runQuickLlmCall(prompt) {
540
526
  const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
541
527
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
542
528
 
543
- const extendedPath = [
544
- `${config.HOME_DIR}/bin`,
545
- `${config.HOME_DIR}/.npm-global/bin`,
546
- `${config.HOME_DIR}/.local/bin`,
547
- `${config.HOME_DIR}/.claude/bin`,
548
- '/usr/local/bin',
549
- '/usr/bin',
550
- '/bin',
551
- ].join(':')
552
-
553
529
  const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json']
554
530
 
555
531
  const child = spawn(binary, args, {
556
532
  cwd: config.HOME_DIR,
557
533
  stdio: ['pipe', 'pipe', 'pipe'],
558
534
  timeout: 30000,
559
- env: {
560
- ...process.env,
561
- HOME: config.HOME_DIR,
562
- PATH: extendedPath,
563
- DISPLAY: ':99',
564
- },
565
535
  })
566
536
 
567
537
  child.stdin.write(prompt)
@@ -14,7 +14,6 @@ const { Cron } = require('croner')
14
14
  const { exec } = require('child_process')
15
15
  const { promisify } = require('util')
16
16
  const crypto = require('crypto')
17
- const path = require('path')
18
17
  const fs = require('fs').promises
19
18
  const execAsync = promisify(exec)
20
19
 
@@ -22,7 +21,7 @@ const { config } = require('../core/config')
22
21
  const executionStore = require('../core/stores/execution-store')
23
22
  const routineStore = require('../core/stores/routine-store')
24
23
  const logManager = require('../core/lib/log-manager')
25
- const variableStore = require('../core/stores/variable-store')
24
+ const runningTasks = require('../core/lib/running-tasks')
26
25
 
27
26
  // Active cron jobs keyed by routine ID
28
27
  const activeJobs = new Map()
@@ -30,9 +29,6 @@ const activeJobs = new Map()
30
29
  // Currently running executions
31
30
  const runningExecutions = new Map()
32
31
 
33
- // Marker file directory (shared with workflow-runner)
34
- const MARKER_DIR = '/tmp/minion-executions'
35
-
36
32
  /**
37
33
  * Sleep for specified milliseconds
38
34
  * @param {number} ms - Milliseconds to sleep
@@ -54,36 +50,6 @@ function generateSessionName(routineId, executionId) {
54
50
  return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
55
51
  }
56
52
 
57
- /**
58
- * Write execution marker file for skill to read
59
- * @param {string} sessionName - tmux session name
60
- * @param {object} data - Execution metadata
61
- */
62
- async function writeMarkerFile(sessionName, data) {
63
- try {
64
- await fs.mkdir(MARKER_DIR, { recursive: true })
65
- const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
66
- await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
67
- console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
68
- } catch (err) {
69
- console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
70
- }
71
- }
72
-
73
- /**
74
- * Clean up marker file after execution
75
- * @param {string} sessionName - tmux session name
76
- */
77
- async function cleanupMarkerFile(sessionName) {
78
- try {
79
- const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
80
- await fs.unlink(filePath)
81
- console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
82
- } catch {
83
- // Ignore if file doesn't exist
84
- }
85
- }
86
-
87
53
  /**
88
54
  * Execute a routine in a single CLI session
89
55
  * All skills run sequentially with context preserved.
@@ -106,17 +72,6 @@ async function executeRoutineSession(routine, executionId, skillNames) {
106
72
 
107
73
  const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
108
74
 
109
- // Extend PATH to include common CLI installation locations
110
- const additionalPaths = [
111
- path.join(homeDir, 'bin'),
112
- path.join(homeDir, '.npm-global', 'bin'),
113
- path.join(homeDir, '.local', 'bin'),
114
- path.join(homeDir, '.claude', 'bin'),
115
- '/usr/local/bin',
116
- ]
117
- const currentPath = process.env.PATH || ''
118
- const extendedPath = [...additionalPaths, currentPath].join(':')
119
-
120
75
  // Exit code file to capture CLI result
121
76
  const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
122
77
 
@@ -139,48 +94,28 @@ async function executeRoutineSession(routine, executionId, skillNames) {
139
94
  // Remove old exit code file
140
95
  await execAsync(`rm -f "${exitCodeFile}"`)
141
96
 
142
- // Write marker file BEFORE starting session
143
- await writeMarkerFile(sessionName, {
144
- execution_id: executionId,
145
- routine_id: routine.id,
146
- routine_name: routine.name,
147
- skill_names: skillNames,
148
- started_at: new Date().toISOString(),
149
- })
150
-
151
97
  // Build the command to run in tmux
152
98
  if (!config.LLM_COMMAND) {
153
99
  throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env (e.g., LLM_COMMAND="claude -p \'{prompt}\'")')
154
100
  }
155
101
  const escapedPrompt = prompt.replace(/'/g, "'\\''")
156
102
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
157
- const execCommand = `${llmCommand}; echo $? > ${exitCodeFile}`
158
-
159
- // Build injected environment: minion variables/secrets (routines don't receive HQ vars)
160
- const injectedEnv = variableStore.buildEnv()
161
103
 
162
- // Create tmux session with extended environment
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, '\\"')}"`)
104
+ // Create tmux session with the LLM command.
105
+ // PATH, HOME, DISPLAY, and minion variables/secrets are already set in
106
+ // process.env at server startup, so child processes inherit them automatically.
107
+ // Per-execution identifiers are passed via -e flags for the session environment.
166
108
  const tmuxCommand = [
167
109
  'tmux new-session -d',
168
110
  `-s "${sessionName}"`,
169
111
  '-x 200 -y 50',
170
- `-e "DISPLAY=:99"`,
171
- `-e "PATH=${extendedPath}"`,
172
- `-e "HOME=${homeDir}"`,
173
112
  `-e "MINION_EXECUTION_ID=${executionId}"`,
174
113
  `-e "MINION_ROUTINE_ID=${routine.id}"`,
175
114
  `-e "MINION_ROUTINE_NAME=${routine.name.replace(/"/g, '\\"')}"`,
176
- ...tmuxEnvFlags,
177
- `"${execCommand}"`,
115
+ `"${llmCommand}; echo $? > ${exitCodeFile}"`,
178
116
  ].join(' ')
179
117
 
180
- await execAsync(tmuxCommand, {
181
- cwd: homeDir,
182
- env: { ...process.env, HOME: homeDir },
183
- })
118
+ await execAsync(tmuxCommand, { cwd: homeDir })
184
119
 
185
120
  // Keep session alive after command completes (for debugging via terminal mirror)
186
121
  await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
@@ -238,8 +173,6 @@ async function executeRoutineSession(routine, executionId, skillNames) {
238
173
  } catch (error) {
239
174
  console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
240
175
  return { success: false, error: error.message, sessionName }
241
- } finally {
242
- await cleanupMarkerFile(sessionName)
243
176
  }
244
177
  }
245
178
 
@@ -269,6 +202,14 @@ async function runRoutine(routine) {
269
202
  session_name: sessionName,
270
203
  })
271
204
 
205
+ // Track in running_tasks for dashboard visibility
206
+ runningTasks.add({
207
+ type: 'routine',
208
+ routine_id: routine.id,
209
+ session_name: sessionName,
210
+ started_at: startedAt,
211
+ })
212
+
272
213
  const logFile = logManager.getLogPath(executionId)
273
214
 
274
215
  // Save: routine running
@@ -310,6 +251,7 @@ async function runRoutine(routine) {
310
251
  await routineStore.updateLastRun(routine.id)
311
252
 
312
253
  runningExecutions.delete(executionId)
254
+ runningTasks.remove(sessionName)
313
255
  console.log(`[RoutineRunner] Completed routine: ${routine.name}`)
314
256
 
315
257
  return { execution_id: executionId, session_name: sessionName }
@@ -418,5 +360,4 @@ module.exports = {
418
360
  runRoutine,
419
361
  getRoutineById,
420
362
  generateSessionName,
421
- MARKER_DIR,
422
363
  }
package/linux/server.js CHANGED
@@ -53,6 +53,7 @@ const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
53
53
  const stepPoller = require('../core/lib/step-poller')
54
54
  const revisionWatcher = require('../core/lib/revision-watcher')
55
55
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
56
+ const runningTasks = require('../core/lib/running-tasks')
56
57
 
57
58
  // Shared routes (from core/)
58
59
  const { healthRoutes, setOffline } = require('../core/routes/health')
@@ -92,11 +93,12 @@ async function shutdown(signal) {
92
93
  heartbeatTimer = null
93
94
  }
94
95
 
95
- // Send offline heartbeat to HQ (best-effort, don't block shutdown)
96
+ // Clear running tasks and send offline heartbeat to HQ (best-effort)
97
+ runningTasks.clear()
96
98
  if (isHqConfigured()) {
97
99
  try {
98
100
  await Promise.race([
99
- sendHeartbeat({ status: 'offline', version }),
101
+ sendHeartbeat({ status: 'offline', running_tasks: [], version }),
100
102
  new Promise(resolve => setTimeout(resolve, 3000)),
101
103
  ])
102
104
  } catch {
@@ -276,6 +278,21 @@ async function registerAllRoutes(app) {
276
278
  // Start server
277
279
  async function start() {
278
280
  try {
281
+ // Extend process.env so all child processes (tmux, spawn) inherit correct environment.
282
+ // This eliminates the need for per-invocation PATH building in runners/chat.
283
+ const { buildExtendedPath } = require('../core/lib/platform')
284
+ process.env.PATH = buildExtendedPath(config.HOME_DIR)
285
+ process.env.HOME = config.HOME_DIR
286
+ process.env.DISPLAY = process.env.DISPLAY || ':99'
287
+
288
+ // Load minion variables/secrets into process.env for child process inheritance
289
+ const variableStore = require('../core/stores/variable-store')
290
+ const minionEnv = variableStore.buildEnv()
291
+ for (const [key, value] of Object.entries(minionEnv)) {
292
+ if (!(key in process.env)) process.env[key] = value
293
+ }
294
+ console.log(`[Server] Loaded ${Object.keys(minionEnv).length} minion variables/secrets into process.env`)
295
+
279
296
  // Sync bundled assets
280
297
  syncBundledRules()
281
298
  syncBundledRoles()
@@ -331,14 +348,14 @@ async function start() {
331
348
  // Send initial online heartbeat
332
349
  const { getStatus } = require('../core/routes/health')
333
350
  const { currentTask } = getStatus()
334
- sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
351
+ sendHeartbeat({ status: 'online', current_task: currentTask, running_tasks: runningTasks.getAll(), version }).catch(err => {
335
352
  console.error('[Heartbeat] Initial heartbeat failed:', err.message)
336
353
  })
337
354
 
338
355
  // Start periodic heartbeat
339
356
  heartbeatTimer = setInterval(() => {
340
357
  const { currentStatus, currentTask } = getStatus()
341
- sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
358
+ sendHeartbeat({ status: currentStatus, current_task: currentTask, running_tasks: runningTasks.getAll(), version }).catch(err => {
342
359
  console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
343
360
  })
344
361
  }, HEARTBEAT_INTERVAL_MS)
@@ -13,15 +13,14 @@ const { Cron } = require('croner')
13
13
  const { exec } = require('child_process')
14
14
  const { promisify } = require('util')
15
15
  const crypto = require('crypto')
16
- const path = require('path')
17
16
  const fs = require('fs').promises
18
17
  const execAsync = promisify(exec)
19
18
 
20
19
  const { config } = require('../core/config')
21
20
  const executionStore = require('../core/stores/execution-store')
22
21
  const workflowStore = require('../core/stores/workflow-store')
23
- const variableStore = require('../core/stores/variable-store')
24
22
  const logManager = require('../core/lib/log-manager')
23
+ const runningTasks = require('../core/lib/running-tasks')
25
24
 
26
25
  // Active cron jobs keyed by workflow ID
27
26
  const activeJobs = new Map()
@@ -76,17 +75,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
76
75
 
77
76
  const prompt = `${rolePrefix}${revisionContext}Run the following skills in order: ${skillCommands}.`
78
77
 
79
- // Extend PATH to include common CLI installation locations
80
- const additionalPaths = [
81
- path.join(homeDir, 'bin'),
82
- path.join(homeDir, '.npm-global', 'bin'),
83
- path.join(homeDir, '.local', 'bin'),
84
- path.join(homeDir, '.claude', 'bin'),
85
- '/usr/local/bin',
86
- ]
87
- const currentPath = process.env.PATH || ''
88
- const extendedPath = [...additionalPaths, currentPath].join(':')
89
-
90
78
  // Exit code file to capture CLI result
91
79
  const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
92
80
 
@@ -116,29 +104,18 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
116
104
  }
117
105
  const escapedPrompt = prompt.replace(/'/g, "'\\''")
118
106
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
119
- const execCommand = `${llmCommand}; echo $? > ${exitCodeFile}`
120
-
121
- // Build injected environment: minion variables/secrets + extra vars from HQ
122
- const injectedEnv = variableStore.buildEnv(options.extraEnv || {})
123
107
 
124
- // Create tmux session with extended environment
125
- const tmuxEnvFlags = Object.entries(injectedEnv)
126
- .map(([k, v]) => `-e "${k}=${v.replace(/"/g, '\\"')}"`)
108
+ // Create tmux session with the LLM command.
109
+ // PATH, HOME, DISPLAY, and minion variables/secrets are already set in
110
+ // process.env at server startup, so child processes inherit them automatically.
127
111
  const tmuxCommand = [
128
112
  'tmux new-session -d',
129
113
  `-s "${sessionName}"`,
130
114
  '-x 200 -y 50',
131
- `-e "DISPLAY=:99"`,
132
- `-e "PATH=${extendedPath}"`,
133
- `-e "HOME=${homeDir}"`,
134
- ...tmuxEnvFlags,
135
- `"${execCommand}"`,
115
+ `"${llmCommand}; echo $? > ${exitCodeFile}"`,
136
116
  ].join(' ')
137
117
 
138
- await execAsync(tmuxCommand, {
139
- cwd: homeDir,
140
- env: { ...process.env, HOME: homeDir },
141
- })
118
+ await execAsync(tmuxCommand, { cwd: homeDir })
142
119
 
143
120
  // Keep session alive after command completes (for debugging via terminal mirror)
144
121
  await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
@@ -237,6 +214,14 @@ async function runWorkflow(workflow, options = {}) {
237
214
  session_name: sessionName,
238
215
  })
239
216
 
217
+ // Track in running_tasks for dashboard visibility
218
+ runningTasks.add({
219
+ type: 'workflow',
220
+ workflow_execution_id: executionId,
221
+ session_name: sessionName,
222
+ started_at: startedAt,
223
+ })
224
+
240
225
  // Log file path for this execution
241
226
  const logFile = logManager.getLogPath(executionId)
242
227
 
@@ -315,6 +300,7 @@ async function runWorkflow(workflow, options = {}) {
315
300
  await workflowStore.updateLastRun(workflow.id)
316
301
 
317
302
  runningExecutions.delete(executionId)
303
+ runningTasks.remove(sessionName)
318
304
  console.log(`[WorkflowRunner] Completed workflow: ${workflow.name}`)
319
305
 
320
306
  return { execution_id: executionId, session_name: sessionName }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.54.1",
3
+ "version": "2.56.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": {
package/roles/engineer.md CHANGED
@@ -25,7 +25,4 @@
25
25
 
26
26
  ## 実行コンテキスト
27
27
 
28
- ワークフロー実行中は以下の環境変数が利用可能:
29
- - `MINION_EXECUTION_ID` — 現在の実行UUID
30
- - `MINION_WORKFLOW_ID` — ワークフローUUID
31
- - `MINION_WORKFLOW_NAME` — ワークフロー名
28
+ ミニオン変数・シークレット(HQ UIまたはAPI経由で設定)はサーバー起動時に `process.env` にロードされ、ワークフロー実行中も利用可能。
package/rules/core.md CHANGED
@@ -62,10 +62,14 @@ minion-cli --version # バージョン確認
62
62
  | `AGENT_PORT` | Agent HTTP port (default: 8080) |
63
63
  | `MINION_USER` | System user running the agent |
64
64
 
65
- Workflow/Routine 実行中は以下も利用可能:
65
+ Routine 実行中は以下もtmuxセッション環境で利用可能:
66
66
  - `MINION_EXECUTION_ID` — 実行UUID
67
- - `MINION_WORKFLOW_ID` / `MINION_ROUTINE_ID` — ワークフロー/ルーティンUUID
68
- - `MINION_WORKFLOW_NAME` / `MINION_ROUTINE_NAME` — 名前
67
+ - `MINION_ROUTINE_ID` — ルーティンUUID
68
+ - `MINION_ROUTINE_NAME` — ルーティン名
69
+
70
+ ミニオン変数・シークレット(HQ UIまたはAPI経由で設定)はサーバー起動時に `process.env` にロードされ、全子プロセスで利用可能。
71
+
72
+ プロジェクト変数・ワークフロー変数はスキル本文の `{{VAR_NAME}}` テンプレートとして展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。
69
73
 
70
74
  ## Skills Directory
71
75
 
@@ -18,7 +18,7 @@ const path = require('path')
18
18
  const { verifyToken } = require('../../core/lib/auth')
19
19
  const { config } = require('../../core/config')
20
20
  const chatStore = require('../../core/stores/chat-store')
21
- const { buildExtendedPath, DATA_DIR } = require('../../core/lib/platform')
21
+ const { DATA_DIR } = require('../../core/lib/platform')
22
22
  const { runEndOfDay } = require('../../core/lib/end-of-day')
23
23
 
24
24
  let activeChatChild = null
@@ -253,9 +253,6 @@ function streamLlmResponse(res, prompt, sessionId) {
253
253
  const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
254
254
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
255
255
 
256
- // Windows-compatible PATH construction
257
- const extendedPath = buildExtendedPath(config.HOME_DIR)
258
-
259
256
  const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
260
257
  if (sessionId) args.push('--resume', sessionId)
261
258
  // Prompt is passed via stdin (not as CLI argument) to avoid
@@ -268,12 +265,6 @@ function streamLlmResponse(res, prompt, sessionId) {
268
265
  stdio: ['pipe', 'pipe', 'pipe'],
269
266
  timeout: 600000,
270
267
  shell: true, // Required on Windows to resolve .cmd shims (e.g. claude.cmd)
271
- env: {
272
- ...process.env,
273
- HOME: config.HOME_DIR,
274
- USERPROFILE: config.HOME_DIR,
275
- PATH: extendedPath,
276
- },
277
268
  })
278
269
 
279
270
  activeChatChild = child
@@ -442,7 +433,6 @@ function runQuickLlmCall(prompt) {
442
433
  }
443
434
  const binaryPath = path.join(config.HOME_DIR, '.local', 'bin', binaryName)
444
435
  const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
445
- const extendedPath = buildExtendedPath(config.HOME_DIR)
446
436
 
447
437
  const args = ['-p', '--model', 'haiku', '--max-turns', '1', '--output-format', 'json']
448
438
 
@@ -451,12 +441,6 @@ function runQuickLlmCall(prompt) {
451
441
  stdio: ['pipe', 'pipe', 'pipe'],
452
442
  timeout: 30000,
453
443
  shell: true,
454
- env: {
455
- ...process.env,
456
- HOME: config.HOME_DIR,
457
- USERPROFILE: config.HOME_DIR,
458
- PATH: extendedPath,
459
- },
460
444
  })
461
445
 
462
446
  child.stdin.write(prompt)
@@ -16,8 +16,6 @@ const { config } = require('../core/config')
16
16
  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
- const { MARKER_DIR, buildExtendedPath } = require('../core/lib/platform')
20
- const variableStore = require('../core/stores/variable-store')
21
19
  const { activeSessions } = require('./workflow-runner')
22
20
 
23
21
  const activeJobs = new Map()
@@ -33,25 +31,6 @@ function generateSessionName(routineId, executionId) {
33
31
  return execShort ? `rt-${routineShort}-${execShort}` : `rt-${routineShort}`
34
32
  }
35
33
 
36
- async function writeMarkerFile(sessionName, data) {
37
- try {
38
- await fs.mkdir(MARKER_DIR, { recursive: true })
39
- const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
40
- await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
41
- console.log(`[RoutineRunner] Wrote marker file: ${filePath}`)
42
- } catch (err) {
43
- console.error(`[RoutineRunner] Failed to write marker file: ${err.message}`)
44
- }
45
- }
46
-
47
- async function cleanupMarkerFile(sessionName) {
48
- try {
49
- const filePath = path.join(MARKER_DIR, `${sessionName}.json`)
50
- await fs.unlink(filePath)
51
- console.log(`[RoutineRunner] Cleaned up marker file: ${filePath}`)
52
- } catch { /* ignore */ }
53
- }
54
-
55
34
  function loadNodePty() {
56
35
  // Prefer prebuilt binaries (no Build Tools required)
57
36
  try { return require('node-pty-prebuilt-multiarch') } catch {}
@@ -74,7 +53,6 @@ async function executeRoutineSession(routine, executionId, skillNames) {
74
53
  : ''
75
54
  const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
76
55
 
77
- const extendedPath = buildExtendedPath(homeDir)
78
56
  const logFile = logManager.getLogPath(executionId)
79
57
 
80
58
  console.log(`[RoutineRunner] Executing routine: ${routine.name}`)
@@ -92,28 +70,16 @@ async function executeRoutineSession(routine, executionId, skillNames) {
92
70
  activeSessions.delete(sessionName)
93
71
  }
94
72
 
95
- await writeMarkerFile(sessionName, {
96
- execution_id: executionId,
97
- routine_id: routine.id,
98
- routine_name: routine.name,
99
- skill_names: skillNames,
100
- started_at: new Date().toISOString(),
101
- })
102
-
103
73
  if (!config.LLM_COMMAND) {
104
74
  throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env')
105
75
  }
106
76
  const escapedPrompt = prompt.replace(/'/g, "''")
107
77
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
108
78
 
109
- // Inject minion variables/secrets (routines don't receive HQ vars)
110
- const injectedEnv = variableStore.buildEnv()
79
+ // PATH, HOME, USERPROFILE, and minion variables/secrets are already set in
80
+ // process.env at server startup. Per-execution identifiers are added here.
111
81
  const env = {
112
82
  ...process.env,
113
- ...injectedEnv,
114
- HOME: homeDir,
115
- USERPROFILE: homeDir,
116
- PATH: extendedPath,
117
83
  MINION_EXECUTION_ID: executionId,
118
84
  MINION_ROUTINE_ID: routine.id,
119
85
  MINION_ROUTINE_NAME: routine.name,
@@ -181,8 +147,6 @@ async function executeRoutineSession(routine, executionId, skillNames) {
181
147
  } catch (error) {
182
148
  console.error(`[RoutineRunner] Routine ${routine.name} failed: ${error.message}`)
183
149
  return { success: false, error: error.message, sessionName }
184
- } finally {
185
- await cleanupMarkerFile(sessionName)
186
150
  }
187
151
  }
188
152
 
@@ -326,5 +290,4 @@ module.exports = {
326
290
  runRoutine,
327
291
  getRoutineById,
328
292
  generateSessionName,
329
- MARKER_DIR,
330
293
  }
package/win/server.js CHANGED
@@ -214,6 +214,21 @@ async function registerRoutes(app) {
214
214
  // Start server
215
215
  async function start() {
216
216
  try {
217
+ // Extend process.env so all child processes (spawn) inherit correct environment.
218
+ // This eliminates the need for per-invocation PATH building in runners/chat.
219
+ const { buildExtendedPath } = require('../core/lib/platform')
220
+ process.env.PATH = buildExtendedPath(config.HOME_DIR)
221
+ process.env.HOME = config.HOME_DIR
222
+ process.env.USERPROFILE = config.HOME_DIR
223
+
224
+ // Load minion variables/secrets into process.env for child process inheritance
225
+ const variableStore = require('../core/stores/variable-store')
226
+ const minionEnv = variableStore.buildEnv()
227
+ for (const [key, value] of Object.entries(minionEnv)) {
228
+ if (!(key in process.env)) process.env[key] = value
229
+ }
230
+ console.log(`[Server] Loaded ${Object.keys(minionEnv).length} minion variables/secrets into process.env`)
231
+
217
232
  // Sync bundled assets
218
233
  syncBundledRules()
219
234
  syncBundledRoles()
@@ -23,8 +23,6 @@ const { config } = require('../core/config')
23
23
  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
- const { buildExtendedPath } = require('../core/lib/platform')
27
- const variableStore = require('../core/stores/variable-store')
28
26
 
29
27
  // Active cron jobs keyed by workflow ID
30
28
  const activeJobs = new Map()
@@ -86,7 +84,6 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
86
84
 
87
85
  const prompt = `${rolePrefix}${revisionContext}Run the following skills in order: ${skillCommands}.`
88
86
 
89
- const extendedPath = buildExtendedPath(homeDir)
90
87
  const logFile = logManager.getLogPath(executionId)
91
88
 
92
89
  console.log(`[WorkflowRunner] Executing workflow: ${workflow.name}`)
@@ -113,15 +110,8 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
113
110
  const escapedPrompt = prompt.replace(/'/g, "''")
114
111
  const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
115
112
 
116
- // Build environment: base + minion variables/secrets + extra vars from HQ
117
- const injectedEnv = variableStore.buildEnv(options.extraEnv || {})
118
- const env = {
119
- ...process.env,
120
- ...injectedEnv,
121
- HOME: homeDir,
122
- USERPROFILE: homeDir,
123
- PATH: extendedPath,
124
- }
113
+ // PATH, HOME, USERPROFILE, and minion variables/secrets are already set in
114
+ // process.env at server startup, so child processes inherit them automatically.
125
115
 
126
116
  // Open log file for streaming writes
127
117
  const logDir = path.dirname(logFile)
@@ -137,7 +127,7 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
137
127
  cols: 200,
138
128
  rows: 50,
139
129
  cwd: homeDir,
140
- env,
130
+ env: process.env,
141
131
  })
142
132
 
143
133
  // Track session