@geekbeer/minion 2.5.1 → 2.10.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/README.md +13 -0
- package/api.js +26 -0
- package/config.js +27 -0
- package/execution-store.js +3 -2
- package/lib/llm-checker.js +114 -0
- package/minion-cli.sh +155 -5
- package/package.json +3 -1
- package/routes/directives.js +163 -0
- package/routes/files.js +23 -9
- package/routes/health.js +2 -0
- package/routes/index.js +15 -0
- package/routes/routines.js +260 -0
- package/routes/skills.js +125 -6
- package/routes/terminal.js +2 -2
- package/routes/workflows.js +16 -3
- package/routine-runner.js +404 -0
- package/routine-store.js +117 -0
- package/rules/minion.md +389 -53
- package/server.js +18 -5
- package/settings/permissions.json +2 -0
- package/workflow-runner.js +14 -11
- package/workflow-store.js +8 -3
package/README.md
CHANGED
|
@@ -37,6 +37,19 @@ minion-cli health # Run health check
|
|
|
37
37
|
minion-cli log -m "Task completed" -l info -s skill-name
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
### Issue Reporting
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
const { reportIssue } = require('@geekbeer/minion/api')
|
|
44
|
+
|
|
45
|
+
await reportIssue({
|
|
46
|
+
title: 'HQ APIで502エラーが発生する',
|
|
47
|
+
body: '## 状況\n...\n\n## 再現手順\n...',
|
|
48
|
+
labels: ['bug', 'critical']
|
|
49
|
+
})
|
|
50
|
+
// → { success: true, issue_url: '...', issue_number: 42 }
|
|
51
|
+
```
|
|
52
|
+
|
|
40
53
|
## Environment Variables
|
|
41
54
|
|
|
42
55
|
| Variable | Description | Default |
|
package/api.js
CHANGED
|
@@ -50,7 +50,33 @@ async function reportExecution(data) {
|
|
|
50
50
|
})
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Report a single workflow step completion to HQ.
|
|
55
|
+
* Called by the post-execution hook after a dispatched skill finishes.
|
|
56
|
+
* @param {object} data - { workflow_execution_id, step_index, status }
|
|
57
|
+
*/
|
|
58
|
+
async function reportStepComplete(data) {
|
|
59
|
+
return request('/step-complete', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
body: JSON.stringify(data),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a GitHub Issue via HQ for bug reports or enhancement suggestions
|
|
67
|
+
* @param {object} data - { title: string, body: string, labels?: string[] }
|
|
68
|
+
* @returns {Promise<{ success: boolean, issue_url: string, issue_number: number }>}
|
|
69
|
+
*/
|
|
70
|
+
async function reportIssue(data) {
|
|
71
|
+
return request('/report', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: JSON.stringify(data),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
module.exports = {
|
|
54
78
|
request,
|
|
55
79
|
reportExecution,
|
|
80
|
+
reportStepComplete,
|
|
81
|
+
reportIssue,
|
|
56
82
|
}
|
package/config.js
CHANGED
|
@@ -11,8 +11,32 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Other optional environment variables:
|
|
13
13
|
* - AGENT_PORT: Port for the local agent server (default: 3001)
|
|
14
|
+
* - MINION_USER: System user running the agent (used to resolve home directory)
|
|
14
15
|
*/
|
|
15
16
|
|
|
17
|
+
const os = require('os')
|
|
18
|
+
const { execSync } = require('child_process')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the correct home directory for the minion user.
|
|
22
|
+
* In supervisord environments, os.homedir() may return /root because
|
|
23
|
+
* the HOME env var is not inherited. This function uses MINION_USER
|
|
24
|
+
* (set in .env during setup) to look up the correct home via getent passwd.
|
|
25
|
+
*/
|
|
26
|
+
function resolveHomeDir() {
|
|
27
|
+
const minionUser = process.env.MINION_USER
|
|
28
|
+
if (minionUser) {
|
|
29
|
+
try {
|
|
30
|
+
const entry = execSync(`getent passwd ${minionUser}`, { encoding: 'utf-8' }).trim()
|
|
31
|
+
const home = entry.split(':')[5]
|
|
32
|
+
if (home) return home
|
|
33
|
+
} catch {
|
|
34
|
+
// getent failed — fall back to os.homedir()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return os.homedir()
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
const config = {
|
|
17
41
|
// HQ Server Configuration (optional - omit for standalone mode)
|
|
18
42
|
HQ_URL: process.env.HQ_URL || '',
|
|
@@ -21,6 +45,9 @@ const config = {
|
|
|
21
45
|
|
|
22
46
|
// Server settings
|
|
23
47
|
AGENT_PORT: parseInt(process.env.AGENT_PORT, 10) || 3001,
|
|
48
|
+
|
|
49
|
+
// Resolved home directory (safe for supervisord environments)
|
|
50
|
+
HOME_DIR: resolveHomeDir(),
|
|
24
51
|
}
|
|
25
52
|
|
|
26
53
|
/**
|
package/execution-store.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs').promises
|
|
8
8
|
const path = require('path')
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
const { config } = require('./config')
|
|
10
11
|
|
|
11
12
|
// Max executions to keep (older ones are pruned)
|
|
12
13
|
const MAX_EXECUTIONS = 200
|
|
@@ -21,7 +22,7 @@ function getExecutionFilePath() {
|
|
|
21
22
|
require('fs').accessSync(path.dirname(optPath))
|
|
22
23
|
return optPath
|
|
23
24
|
} catch {
|
|
24
|
-
return path.join(
|
|
25
|
+
return path.join(config.HOME_DIR, 'executions.json')
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Service authentication checker
|
|
3
|
+
*
|
|
4
|
+
* Detects whether supported LLM CLIs are authenticated and ready to use.
|
|
5
|
+
* CLIs are pre-installed on all minions; this module only checks auth status.
|
|
6
|
+
* Results are cached in memory for 60 seconds to avoid excessive filesystem checks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
const path = require('path')
|
|
11
|
+
const { config } = require('../config')
|
|
12
|
+
|
|
13
|
+
const CACHE_TTL_MS = 60000
|
|
14
|
+
|
|
15
|
+
let cachedResult = null
|
|
16
|
+
let cachedAt = 0
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check Claude Code authentication.
|
|
20
|
+
* Claude stores OAuth credentials in ~/.claude/.credentials.json
|
|
21
|
+
*/
|
|
22
|
+
function isClaudeAuthenticated() {
|
|
23
|
+
const candidates = [
|
|
24
|
+
path.join(config.HOME_DIR, '.claude', '.credentials.json'),
|
|
25
|
+
path.join(config.HOME_DIR, '.claude', 'credentials.json'),
|
|
26
|
+
]
|
|
27
|
+
for (const p of candidates) {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(p)) {
|
|
30
|
+
const content = fs.readFileSync(p, 'utf-8')
|
|
31
|
+
const parsed = JSON.parse(content)
|
|
32
|
+
if (parsed && Object.keys(parsed).length > 0) return true
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Invalid JSON or read error — not authenticated
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check Gemini CLI authentication.
|
|
43
|
+
* Gemini uses Google OAuth tokens or API key env vars.
|
|
44
|
+
*/
|
|
45
|
+
function isGeminiAuthenticated() {
|
|
46
|
+
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return true
|
|
47
|
+
|
|
48
|
+
const possiblePaths = [
|
|
49
|
+
path.join(config.HOME_DIR, '.config', 'gemini'),
|
|
50
|
+
path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
|
|
51
|
+
]
|
|
52
|
+
for (const p of possiblePaths) {
|
|
53
|
+
try {
|
|
54
|
+
if (!fs.existsSync(p)) continue
|
|
55
|
+
const stat = fs.statSync(p)
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
if (fs.readdirSync(p).length > 0) return true
|
|
58
|
+
} else {
|
|
59
|
+
if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check Codex (OpenAI) authentication.
|
|
70
|
+
* Codex CLI uses OPENAI_API_KEY or config in ~/.codex/
|
|
71
|
+
*/
|
|
72
|
+
function isCodexAuthenticated() {
|
|
73
|
+
if (process.env.OPENAI_API_KEY) return true
|
|
74
|
+
|
|
75
|
+
const codexConfig = path.join(config.HOME_DIR, '.codex')
|
|
76
|
+
try {
|
|
77
|
+
if (fs.existsSync(codexConfig) && fs.statSync(codexConfig).isDirectory()) {
|
|
78
|
+
if (fs.readdirSync(codexConfig).length > 0) return true
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SERVICE_DEFINITIONS = [
|
|
87
|
+
{ name: 'claude', display_name: 'Claude Code', check: isClaudeAuthenticated },
|
|
88
|
+
{ name: 'gemini', display_name: 'Gemini CLI', check: isGeminiAuthenticated },
|
|
89
|
+
{ name: 'codex', display_name: 'Codex', check: isCodexAuthenticated },
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get authenticated LLM services (cached for 60s).
|
|
94
|
+
* Returns all services with their authentication status.
|
|
95
|
+
* @returns {{ name: string, display_name: string, authenticated: boolean }[]}
|
|
96
|
+
*/
|
|
97
|
+
function getLlmServices() {
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
|
|
100
|
+
return cachedResult
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const services = SERVICE_DEFINITIONS.map(({ name, display_name, check }) => ({
|
|
104
|
+
name,
|
|
105
|
+
display_name,
|
|
106
|
+
authenticated: check(),
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
cachedResult = services
|
|
110
|
+
cachedAt = now
|
|
111
|
+
return services
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { getLlmServices }
|
package/minion-cli.sh
CHANGED
|
@@ -88,6 +88,17 @@ svc_control() {
|
|
|
88
88
|
|
|
89
89
|
AGENT_URL="${MINION_AGENT_URL:-http://localhost:3001}"
|
|
90
90
|
|
|
91
|
+
# Auto-load .env so that API_TOKEN etc. are available in interactive shells
|
|
92
|
+
ENV_FILE="/opt/minion-agent/.env"
|
|
93
|
+
if [ -f "$ENV_FILE" ] && [ -r "$ENV_FILE" ]; then
|
|
94
|
+
while IFS='=' read -r key value; do
|
|
95
|
+
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
|
|
96
|
+
if [ -z "${!key:-}" ]; then
|
|
97
|
+
export "$key=$value"
|
|
98
|
+
fi
|
|
99
|
+
done < "$ENV_FILE"
|
|
100
|
+
fi
|
|
101
|
+
|
|
91
102
|
# ============================================================
|
|
92
103
|
# setup subcommand
|
|
93
104
|
# ============================================================
|
|
@@ -286,11 +297,11 @@ do_setup() {
|
|
|
286
297
|
case "$PROC_MGR" in
|
|
287
298
|
systemd)
|
|
288
299
|
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart minion-agent"
|
|
289
|
-
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart xvfb fluxbox
|
|
300
|
+
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart xvfb fluxbox autocutsel vnc novnc"
|
|
290
301
|
;;
|
|
291
302
|
supervisord)
|
|
292
303
|
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart minion-agent"
|
|
293
|
-
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart xvfb fluxbox
|
|
304
|
+
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl restart xvfb fluxbox autocutsel vnc novnc"
|
|
294
305
|
SUDOERS_CONTENT+="\n${TARGET_USER} ALL=(root) NOPASSWD: /usr/bin/supervisorctl status"
|
|
295
306
|
;;
|
|
296
307
|
esac
|
|
@@ -354,12 +365,142 @@ Environment=MINION_USER=${TARGET_USER}
|
|
|
354
365
|
WantedBy=multi-user.target
|
|
355
366
|
SVCEOF
|
|
356
367
|
echo " -> /etc/systemd/system/minion-agent.service created"
|
|
368
|
+
|
|
369
|
+
# VNC stack services (only if Xvfb is installed)
|
|
370
|
+
if command -v Xvfb &>/dev/null; then
|
|
371
|
+
echo " Creating VNC stack services..."
|
|
372
|
+
|
|
373
|
+
# xvfb: virtual display (-ac disables access control so any user can connect)
|
|
374
|
+
$SUDO tee /etc/systemd/system/xvfb.service > /dev/null <<XVFBEOF
|
|
375
|
+
[Unit]
|
|
376
|
+
Description=Xvfb virtual framebuffer
|
|
377
|
+
After=network.target
|
|
378
|
+
|
|
379
|
+
[Service]
|
|
380
|
+
Type=simple
|
|
381
|
+
ExecStart=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -ac
|
|
382
|
+
Restart=always
|
|
383
|
+
RestartSec=5
|
|
384
|
+
|
|
385
|
+
[Install]
|
|
386
|
+
WantedBy=multi-user.target
|
|
387
|
+
XVFBEOF
|
|
388
|
+
echo " -> /etc/systemd/system/xvfb.service created"
|
|
389
|
+
|
|
390
|
+
# fluxbox: window manager (runs as TARGET_USER so terminals open as that user)
|
|
391
|
+
$SUDO tee /etc/systemd/system/fluxbox.service > /dev/null <<FBEOF
|
|
392
|
+
[Unit]
|
|
393
|
+
Description=Fluxbox window manager
|
|
394
|
+
After=xvfb.service
|
|
395
|
+
Requires=xvfb.service
|
|
396
|
+
|
|
397
|
+
[Service]
|
|
398
|
+
Type=simple
|
|
399
|
+
User=${TARGET_USER}
|
|
400
|
+
Environment=DISPLAY=:99
|
|
401
|
+
ExecStart=/usr/bin/fluxbox
|
|
402
|
+
Restart=always
|
|
403
|
+
RestartSec=5
|
|
404
|
+
|
|
405
|
+
[Install]
|
|
406
|
+
WantedBy=multi-user.target
|
|
407
|
+
FBEOF
|
|
408
|
+
echo " -> /etc/systemd/system/fluxbox.service created (User=${TARGET_USER})"
|
|
409
|
+
|
|
410
|
+
# autocutsel: clipboard sync (runs as TARGET_USER to match desktop session)
|
|
411
|
+
if command -v autocutsel &>/dev/null; then
|
|
412
|
+
$SUDO tee /etc/systemd/system/autocutsel.service > /dev/null <<ACEOF
|
|
413
|
+
[Unit]
|
|
414
|
+
Description=X clipboard synchronization
|
|
415
|
+
After=xvfb.service
|
|
416
|
+
Requires=xvfb.service
|
|
417
|
+
|
|
418
|
+
[Service]
|
|
419
|
+
Type=forking
|
|
420
|
+
User=${TARGET_USER}
|
|
421
|
+
Environment=DISPLAY=:99
|
|
422
|
+
ExecStart=/usr/bin/autocutsel -fork
|
|
423
|
+
Restart=always
|
|
424
|
+
RestartSec=5
|
|
425
|
+
|
|
426
|
+
[Install]
|
|
427
|
+
WantedBy=multi-user.target
|
|
428
|
+
ACEOF
|
|
429
|
+
echo " -> /etc/systemd/system/autocutsel.service created (User=${TARGET_USER})"
|
|
430
|
+
fi
|
|
431
|
+
|
|
432
|
+
# vnc: VNC server (detect x0vncserver or x11vnc)
|
|
433
|
+
if command -v x0vncserver &>/dev/null; then
|
|
434
|
+
$SUDO tee /etc/systemd/system/vnc.service > /dev/null <<VNCEOF
|
|
435
|
+
[Unit]
|
|
436
|
+
Description=TigerVNC scraping server
|
|
437
|
+
After=fluxbox.service
|
|
438
|
+
Requires=xvfb.service
|
|
439
|
+
|
|
440
|
+
[Service]
|
|
441
|
+
Type=simple
|
|
442
|
+
Environment=DISPLAY=:99
|
|
443
|
+
ExecStart=/usr/bin/x0vncserver -display :99 -rfbport 5900 -SecurityTypes None -fg
|
|
444
|
+
Restart=always
|
|
445
|
+
RestartSec=5
|
|
446
|
+
|
|
447
|
+
[Install]
|
|
448
|
+
WantedBy=multi-user.target
|
|
449
|
+
VNCEOF
|
|
450
|
+
echo " -> /etc/systemd/system/vnc.service created (x0vncserver)"
|
|
451
|
+
elif command -v x11vnc &>/dev/null; then
|
|
452
|
+
$SUDO tee /etc/systemd/system/vnc.service > /dev/null <<VNCEOF
|
|
453
|
+
[Unit]
|
|
454
|
+
Description=x11vnc VNC server
|
|
455
|
+
After=fluxbox.service
|
|
456
|
+
Requires=xvfb.service
|
|
457
|
+
|
|
458
|
+
[Service]
|
|
459
|
+
Type=simple
|
|
460
|
+
Environment=DISPLAY=:99
|
|
461
|
+
ExecStart=/usr/bin/x11vnc -display :99 -rfbport 5900 -nopw -forever -shared
|
|
462
|
+
Restart=always
|
|
463
|
+
RestartSec=5
|
|
464
|
+
|
|
465
|
+
[Install]
|
|
466
|
+
WantedBy=multi-user.target
|
|
467
|
+
VNCEOF
|
|
468
|
+
echo " -> /etc/systemd/system/vnc.service created (x11vnc)"
|
|
469
|
+
else
|
|
470
|
+
echo " WARNING: No VNC server found (x0vncserver or x11vnc)"
|
|
471
|
+
fi
|
|
472
|
+
|
|
473
|
+
# novnc: WebSocket proxy for browser access
|
|
474
|
+
if command -v websockify &>/dev/null; then
|
|
475
|
+
local NOVNC_WEB="/usr/share/novnc"
|
|
476
|
+
if [ ! -d "$NOVNC_WEB" ]; then
|
|
477
|
+
NOVNC_WEB="/usr/share/novnc/utils/../"
|
|
478
|
+
fi
|
|
479
|
+
$SUDO tee /etc/systemd/system/novnc.service > /dev/null <<NVNCEOF
|
|
480
|
+
[Unit]
|
|
481
|
+
Description=noVNC WebSocket proxy
|
|
482
|
+
After=vnc.service
|
|
483
|
+
Requires=vnc.service
|
|
484
|
+
|
|
485
|
+
[Service]
|
|
486
|
+
Type=simple
|
|
487
|
+
ExecStart=/usr/bin/websockify --web=${NOVNC_WEB} 6080 localhost:5900
|
|
488
|
+
Restart=always
|
|
489
|
+
RestartSec=5
|
|
490
|
+
|
|
491
|
+
[Install]
|
|
492
|
+
WantedBy=multi-user.target
|
|
493
|
+
NVNCEOF
|
|
494
|
+
echo " -> /etc/systemd/system/novnc.service created"
|
|
495
|
+
fi
|
|
496
|
+
fi
|
|
357
497
|
;;
|
|
358
498
|
|
|
359
499
|
supervisord)
|
|
360
500
|
# Build environment line from .env values
|
|
501
|
+
# Include HOME and DISPLAY since supervisord does not set them when switching user
|
|
361
502
|
local ENV_LINE="environment="
|
|
362
|
-
local ENV_PAIRS=()
|
|
503
|
+
local ENV_PAIRS=("HOME=\"${TARGET_HOME}\"" "DISPLAY=\":99\"")
|
|
363
504
|
while IFS='=' read -r key value; do
|
|
364
505
|
[[ -z "$key" || "$key" == \#* ]] && continue
|
|
365
506
|
ENV_PAIRS+=("${key}=\"${value}\"")
|
|
@@ -394,11 +535,20 @@ SUPEOF
|
|
|
394
535
|
;;
|
|
395
536
|
esac
|
|
396
537
|
|
|
397
|
-
# Step 7: Enable and start
|
|
398
|
-
echo "[7/${TOTAL_STEPS}] Starting
|
|
538
|
+
# Step 7: Enable and start services
|
|
539
|
+
echo "[7/${TOTAL_STEPS}] Starting services..."
|
|
399
540
|
case "$PROC_MGR" in
|
|
400
541
|
systemd)
|
|
401
542
|
$SUDO systemctl daemon-reload
|
|
543
|
+
# Enable and start VNC stack (if service files were created)
|
|
544
|
+
for svc in xvfb fluxbox autocutsel vnc novnc; do
|
|
545
|
+
if [ -f "/etc/systemd/system/${svc}.service" ]; then
|
|
546
|
+
$SUDO systemctl enable "$svc"
|
|
547
|
+
$SUDO systemctl restart "$svc"
|
|
548
|
+
echo " -> ${svc} started"
|
|
549
|
+
fi
|
|
550
|
+
done
|
|
551
|
+
# Enable and start agent services
|
|
402
552
|
$SUDO systemctl enable tmux-init
|
|
403
553
|
$SUDO systemctl start tmux-init
|
|
404
554
|
$SUDO systemctl enable minion-agent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekbeer/minion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
4
4
|
"description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"terminal-proxy.js",
|
|
14
14
|
"workflow-runner.js",
|
|
15
15
|
"workflow-store.js",
|
|
16
|
+
"routine-runner.js",
|
|
17
|
+
"routine-store.js",
|
|
16
18
|
"execution-store.js",
|
|
17
19
|
"lib/",
|
|
18
20
|
"routes/",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive endpoints
|
|
3
|
+
*
|
|
4
|
+
* Receives temp skill directives from HQ and executes them.
|
|
5
|
+
* Used for one-shot workflow orchestration via system-embedded skills.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* - POST /api/directive - Receive and execute a temp skill directive
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs').promises
|
|
12
|
+
const path = require('path')
|
|
13
|
+
const crypto = require('crypto')
|
|
14
|
+
|
|
15
|
+
const { verifyToken } = require('../lib/auth')
|
|
16
|
+
const { config } = require('../config')
|
|
17
|
+
const { writeSkillToLocal } = require('./skills')
|
|
18
|
+
const workflowRunner = require('../workflow-runner')
|
|
19
|
+
const executionStore = require('../execution-store')
|
|
20
|
+
const logManager = require('../lib/log-manager')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse frontmatter from skill content to extract body
|
|
24
|
+
* @param {string} content - Full skill content with frontmatter
|
|
25
|
+
* @returns {{ metadata: object, body: string }}
|
|
26
|
+
*/
|
|
27
|
+
function parseFrontmatter(content) {
|
|
28
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
29
|
+
if (!match) return { metadata: {}, body: content }
|
|
30
|
+
|
|
31
|
+
const metadata = {}
|
|
32
|
+
for (const line of match[1].split('\n')) {
|
|
33
|
+
const [key, ...rest] = line.split(':')
|
|
34
|
+
if (key && rest.length) {
|
|
35
|
+
metadata[key.trim()] = rest.join(':').trim()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { metadata, body: match[2].trimStart() }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register directive routes as Fastify plugin
|
|
43
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
44
|
+
*/
|
|
45
|
+
async function directiveRoutes(fastify) {
|
|
46
|
+
// Receive and execute a temp skill directive from HQ
|
|
47
|
+
fastify.post('/api/directive', async (request, reply) => {
|
|
48
|
+
if (!verifyToken(request)) {
|
|
49
|
+
reply.code(401)
|
|
50
|
+
return { success: false, error: 'Unauthorized' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { skill_name, skill_content, execution_id, context } = request.body || {}
|
|
54
|
+
|
|
55
|
+
if (!skill_name || !skill_content) {
|
|
56
|
+
reply.code(400)
|
|
57
|
+
return { success: false, error: 'skill_name and skill_content are required' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Validate temp skill name (must start with __)
|
|
61
|
+
if (!skill_name.startsWith('__')) {
|
|
62
|
+
reply.code(400)
|
|
63
|
+
return { success: false, error: 'Directive skill names must start with __' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const effectiveExecutionId = execution_id || crypto.randomUUID()
|
|
67
|
+
const sessionName = `dir-${effectiveExecutionId.substring(0, 8)}-${effectiveExecutionId.substring(8, 12)}`
|
|
68
|
+
|
|
69
|
+
console.log(`[Directive] Received directive: ${skill_name} (execution: ${effectiveExecutionId})`)
|
|
70
|
+
console.log(`[Directive] Session: ${sessionName}`)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// 1. Write temp skill to local filesystem
|
|
74
|
+
// The skill_content is the full content with frontmatter — write as-is
|
|
75
|
+
const homeDir = config.HOME_DIR
|
|
76
|
+
const skillDir = path.join(homeDir, '.claude', 'skills', skill_name)
|
|
77
|
+
await fs.mkdir(skillDir, { recursive: true })
|
|
78
|
+
await fs.writeFile(path.join(skillDir, 'SKILL.md'), skill_content, 'utf-8')
|
|
79
|
+
|
|
80
|
+
console.log(`[Directive] Temp skill written: ${skillDir}`)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[Directive] Failed to write temp skill: ${err.message}`)
|
|
83
|
+
reply.code(500)
|
|
84
|
+
return { success: false, error: `Failed to write temp skill: ${err.message}` }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const startedAt = new Date().toISOString()
|
|
88
|
+
const logFile = logManager.getLogPath(effectiveExecutionId)
|
|
89
|
+
const workflowName = context?.workflow_name || skill_name
|
|
90
|
+
|
|
91
|
+
// Save initial execution record
|
|
92
|
+
await executionStore.save({
|
|
93
|
+
id: effectiveExecutionId,
|
|
94
|
+
skill_name,
|
|
95
|
+
workflow_id: null,
|
|
96
|
+
workflow_name: workflowName,
|
|
97
|
+
status: 'running',
|
|
98
|
+
outcome: null,
|
|
99
|
+
started_at: startedAt,
|
|
100
|
+
completed_at: null,
|
|
101
|
+
parent_execution_id: null,
|
|
102
|
+
error_message: null,
|
|
103
|
+
log_file: logFile,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// 2. Run async — respond immediately with 202
|
|
107
|
+
const executionPromise = (async () => {
|
|
108
|
+
const homeDir = config.HOME_DIR
|
|
109
|
+
const skillDir = path.join(homeDir, '.claude', 'skills', skill_name)
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Execute as a single-skill workflow
|
|
113
|
+
// skipExecutionReport: the orchestration template has its own
|
|
114
|
+
// completion report (section 5), so /execution-report is redundant
|
|
115
|
+
// and would use the wrong (local) execution ID.
|
|
116
|
+
const result = await workflowRunner.runWorkflow({
|
|
117
|
+
id: effectiveExecutionId,
|
|
118
|
+
name: workflowName,
|
|
119
|
+
pipeline_skill_names: [skill_name],
|
|
120
|
+
}, { skipExecutionReport: true })
|
|
121
|
+
|
|
122
|
+
console.log(`[Directive] Execution completed: ${skill_name} (success: ${result.execution_id ? 'yes' : 'no'})`)
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`[Directive] Execution failed: ${err.message}`)
|
|
125
|
+
await executionStore.save({
|
|
126
|
+
id: effectiveExecutionId,
|
|
127
|
+
skill_name,
|
|
128
|
+
workflow_id: null,
|
|
129
|
+
workflow_name: workflowName,
|
|
130
|
+
status: 'failed',
|
|
131
|
+
outcome: 'failure',
|
|
132
|
+
started_at: startedAt,
|
|
133
|
+
completed_at: new Date().toISOString(),
|
|
134
|
+
parent_execution_id: null,
|
|
135
|
+
error_message: err.message,
|
|
136
|
+
log_file: logFile,
|
|
137
|
+
})
|
|
138
|
+
} finally {
|
|
139
|
+
// 3. Cleanup temp skill directory
|
|
140
|
+
try {
|
|
141
|
+
await fs.rm(skillDir, { recursive: true, force: true })
|
|
142
|
+
console.log(`[Directive] Temp skill cleaned up: ${skillDir}`)
|
|
143
|
+
} catch (cleanupErr) {
|
|
144
|
+
console.error(`[Directive] Failed to cleanup temp skill: ${cleanupErr.message}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})()
|
|
148
|
+
|
|
149
|
+
executionPromise.catch(err => {
|
|
150
|
+
console.error(`[Directive] Unhandled error: ${err.message}`)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
reply.code(202)
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
session_name: sessionName,
|
|
157
|
+
execution_id: effectiveExecutionId,
|
|
158
|
+
message: 'Directive accepted',
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { directiveRoutes }
|
package/routes/files.js
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
const fs = require('fs').promises
|
|
15
15
|
const fsSync = require('fs')
|
|
16
16
|
const path = require('path')
|
|
17
|
-
const
|
|
17
|
+
const { spawn } = require('child_process')
|
|
18
18
|
|
|
19
19
|
const { verifyToken } = require('../lib/auth')
|
|
20
|
+
const { config } = require('../config')
|
|
20
21
|
|
|
21
22
|
/** Base directory for file storage */
|
|
22
|
-
const FILES_DIR = path.join(
|
|
23
|
+
const FILES_DIR = path.join(config.HOME_DIR, 'files')
|
|
23
24
|
|
|
24
25
|
/** Max upload size: 50MB */
|
|
25
26
|
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
|
@@ -124,7 +125,7 @@ async function fileRoutes(fastify) {
|
|
|
124
125
|
}
|
|
125
126
|
})
|
|
126
127
|
|
|
127
|
-
// GET /api/files/* - Download a file
|
|
128
|
+
// GET /api/files/* - Download a file or directory (with ?format=tar.gz for directories)
|
|
128
129
|
fastify.get('/api/files/*', async (request, reply) => {
|
|
129
130
|
if (!verifyToken(request)) {
|
|
130
131
|
reply.code(401)
|
|
@@ -146,12 +147,6 @@ async function fileRoutes(fastify) {
|
|
|
146
147
|
try {
|
|
147
148
|
const stat = await fs.lstat(resolved)
|
|
148
149
|
|
|
149
|
-
// Don't allow downloading directories
|
|
150
|
-
if (stat.isDirectory()) {
|
|
151
|
-
reply.code(400)
|
|
152
|
-
return { success: false, error: 'Cannot download a directory' }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
150
|
// Check symlink safety
|
|
156
151
|
if (stat.isSymbolicLink()) {
|
|
157
152
|
const realPath = await fs.realpath(resolved)
|
|
@@ -161,6 +156,25 @@ async function fileRoutes(fastify) {
|
|
|
161
156
|
}
|
|
162
157
|
}
|
|
163
158
|
|
|
159
|
+
// Directory download as tar.gz
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
const format = request.query.format
|
|
162
|
+
if (format !== 'tar.gz') {
|
|
163
|
+
reply.code(400)
|
|
164
|
+
return { success: false, error: 'Cannot download a directory. Use ?format=tar.gz' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const dirName = path.basename(resolved)
|
|
168
|
+
const parentDir = path.dirname(resolved)
|
|
169
|
+
|
|
170
|
+
reply
|
|
171
|
+
.type('application/gzip')
|
|
172
|
+
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`)
|
|
173
|
+
|
|
174
|
+
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, dirName])
|
|
175
|
+
return reply.send(tar.stdout)
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
const filename = path.basename(resolved)
|
|
165
179
|
reply
|
|
166
180
|
.type('application/octet-stream')
|
package/routes/health.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { version } = require('../package.json')
|
|
11
|
+
const { getLlmServices } = require('../lib/llm-checker')
|
|
11
12
|
|
|
12
13
|
// Shared status state
|
|
13
14
|
let currentStatus = 'online'
|
|
@@ -48,6 +49,7 @@ async function healthRoutes(fastify) {
|
|
|
48
49
|
uptime: process.uptime(),
|
|
49
50
|
version,
|
|
50
51
|
timestamp: new Date().toISOString(),
|
|
52
|
+
llm_services: getLlmServices(),
|
|
51
53
|
}
|
|
52
54
|
})
|
|
53
55
|
|