@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 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
  /**
@@ -6,7 +6,8 @@
6
6
 
7
7
  const fs = require('fs').promises
8
8
  const path = require('path')
9
- const os = require('os')
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(os.homedir(), 'executions.json')
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 x11vnc novnc"
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 x11vnc novnc"
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 service
398
- echo "[7/${TOTAL_STEPS}] Starting minion-agent service..."
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.5.1",
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 os = require('os')
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(os.homedir(), 'files')
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