@geekbeer/minion 1.3.3 → 1.4.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/minion-cli.sh CHANGED
@@ -24,6 +24,22 @@ set -euo pipefail
24
24
  # Ensure HOME is set (cloud-init may not set it)
25
25
  export HOME="${HOME:-$(getent passwd "$(whoami)" | cut -d: -f6)}"
26
26
 
27
+ # Detect target user: when running as root (e.g., cloud-init), install user-level
28
+ # tools for the 'ubuntu' user instead of root
29
+ if [ "$(id -u)" -eq 0 ] && getent passwd ubuntu &>/dev/null; then
30
+ TARGET_USER="ubuntu"
31
+ TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6)
32
+ RUN_AS="sudo -u $TARGET_USER"
33
+ elif [ "$(id -u)" -eq 0 ]; then
34
+ TARGET_USER="root"
35
+ TARGET_HOME="$HOME"
36
+ RUN_AS=""
37
+ else
38
+ TARGET_USER="$(whoami)"
39
+ TARGET_HOME="$HOME"
40
+ RUN_AS=""
41
+ fi
42
+
27
43
  # Resolve version from package.json (installed location)
28
44
  CLI_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
29
45
  CLI_VERSION="$(node -p "require('${CLI_DIR}/package.json').version")"
@@ -144,13 +160,14 @@ do_setup() {
144
160
 
145
161
  # Step 1: Install Claude Code
146
162
  echo "[1/${TOTAL_STEPS}] Installing Claude Code..."
147
- if command -v claude &>/dev/null; then
148
- echo " -> Claude Code already installed ($(claude --version 2>/dev/null || echo 'unknown version'))"
163
+ # Install as target user (claude native installer writes to $HOME/.local/bin)
164
+ # Note: sudo -u resets PATH, so we must set it explicitly in subshells
165
+ export PATH="${TARGET_HOME}/.local/bin:$HOME/.local/bin:$PATH"
166
+ if $RUN_AS bash -c 'export PATH="$HOME/.local/bin:$PATH"; command -v claude' &>/dev/null; then
167
+ echo " -> Claude Code already installed ($($RUN_AS bash -c 'export PATH="$HOME/.local/bin:$PATH"; claude --version' 2>/dev/null || echo 'unknown version'))"
149
168
  else
150
- curl -fsSL https://claude.ai/install.sh | bash
151
- # Reload PATH to pick up newly installed binary
152
- export PATH="$HOME/.local/bin:$PATH"
153
- if command -v claude &>/dev/null; then
169
+ $RUN_AS bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
170
+ if $RUN_AS bash -c 'export PATH="$HOME/.local/bin:$PATH"; command -v claude' &>/dev/null; then
154
171
  echo " -> Claude Code installed successfully"
155
172
  else
156
173
  echo " WARNING: Claude Code installation may have failed"
@@ -321,11 +338,11 @@ SUPEOF
321
338
  # Step 8: Deploy bundled skills
322
339
  echo "[8/${TOTAL_STEPS}] Deploying bundled skills..."
323
340
  local BUNDLED_SKILLS_DIR="${NPM_ROOT}/@geekbeer/minion/skills"
324
- local CLAUDE_SKILLS_DIR="$HOME/.claude/skills"
341
+ local CLAUDE_SKILLS_DIR="${TARGET_HOME}/.claude/skills"
325
342
 
326
343
  if [ -d "$BUNDLED_SKILLS_DIR" ]; then
327
344
  # Create Claude skills directory if it doesn't exist
328
- mkdir -p "$CLAUDE_SKILLS_DIR"
345
+ $RUN_AS mkdir -p "$CLAUDE_SKILLS_DIR"
329
346
 
330
347
  # Copy each bundled skill
331
348
  for skill_dir in "$BUNDLED_SKILLS_DIR"/*/; do
@@ -334,7 +351,7 @@ SUPEOF
334
351
  local target_dir="${CLAUDE_SKILLS_DIR}/${skill_name}"
335
352
 
336
353
  # Copy skill files
337
- cp -r "$skill_dir" "$target_dir"
354
+ $RUN_AS cp -r "$skill_dir" "$target_dir"
338
355
  echo " -> Deployed skill: $skill_name"
339
356
  fi
340
357
  done
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "server.js",
11
11
  "config.js",
12
12
  "api.js",
13
+ "terminal-proxy.js",
13
14
  "workflow-runner.js",
14
15
  "workflow-store.js",
15
16
  "execution-store.js",
@@ -34,8 +34,8 @@ const homeDir = os.homedir()
34
34
  */
35
35
  const ttydProcesses = new Map()
36
36
 
37
- // Base port for ttyd instances (will increment for each session)
38
- const TTYD_BASE_PORT = 7681
37
+ // Base port for ttyd instances (7681 is reserved for the WebSocket proxy)
38
+ const TTYD_BASE_PORT = 7682
39
39
 
40
40
  /**
41
41
  * Find an available port for ttyd
@@ -63,6 +63,75 @@ async function isTtydInstalled() {
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * Start ttyd for a tmux session (shared logic used by routes and proxy).
68
+ * @param {string} session - tmux session name
69
+ * @returns {Promise<{port: number, alreadyRunning: boolean}>}
70
+ */
71
+ async function startTtydForSession(session) {
72
+ // Check if already running
73
+ const existing = ttydProcesses.get(session)
74
+ if (existing && !existing.proc.killed) {
75
+ return { port: existing.port, alreadyRunning: true }
76
+ }
77
+
78
+ // Check if ttyd is installed
79
+ const installed = await isTtydInstalled()
80
+ if (!installed) {
81
+ throw new Error('ttyd is not installed')
82
+ }
83
+
84
+ // Verify tmux session exists
85
+ try {
86
+ await execAsync(`tmux has-session -t "${session}"`, {
87
+ timeout: 5000,
88
+ env: { ...process.env, HOME: homeDir },
89
+ })
90
+ } catch {
91
+ throw new Error(`tmux session '${session}' not found`)
92
+ }
93
+
94
+ const port = findAvailablePort()
95
+
96
+ const proc = spawn('ttyd', [
97
+ '-p', port.toString(),
98
+ '-t', 'fontSize=14',
99
+ '-t', 'theme={"background": "#1a1a2e", "foreground": "#eee"}',
100
+ 'tmux', 'attach', '-t', session,
101
+ ], {
102
+ env: { ...process.env, HOME: homeDir },
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ detached: false,
105
+ })
106
+
107
+ proc.on('error', (err) => {
108
+ console.error(`[Terminal] ttyd error for session '${session}': ${err.message}`)
109
+ ttydProcesses.delete(session)
110
+ })
111
+
112
+ proc.on('exit', (code) => {
113
+ console.log(`[Terminal] ttyd exited for session '${session}' with code ${code}`)
114
+ ttydProcesses.delete(session)
115
+ })
116
+
117
+ proc.stderr.on('data', (data) => {
118
+ console.log(`[Terminal] ttyd[${session}]: ${data.toString().trim()}`)
119
+ })
120
+
121
+ ttydProcesses.set(session, {
122
+ proc,
123
+ port,
124
+ startedAt: new Date().toISOString(),
125
+ })
126
+
127
+ console.log(`[Terminal] Started ttyd for session '${session}' on port ${port}`)
128
+
129
+ // Wait for ttyd to start listening
130
+ await new Promise(resolve => setTimeout(resolve, 500))
131
+
132
+ return { port, alreadyRunning: false }
133
+ }
134
+
66
135
  /**
67
136
  * Register terminal routes as Fastify plugin
68
137
  * @param {import('fastify').FastifyInstance} fastify
@@ -446,103 +515,28 @@ async function terminalRoutes(fastify) {
446
515
  return { success: false, error: 'session is required' }
447
516
  }
448
517
 
449
- // Sanitize session name
450
518
  if (!/^[\w-]+$/.test(session)) {
451
519
  reply.code(400)
452
520
  return { success: false, error: 'Invalid session name' }
453
521
  }
454
522
 
455
- // Check if ttyd is installed
456
- const installed = await isTtydInstalled()
457
- if (!installed) {
458
- reply.code(503)
459
- return {
460
- success: false,
461
- error: 'ttyd is not installed. Install with: apt-get install ttyd',
462
- }
463
- }
464
-
465
- // Check if already running for this session
466
- const existing = ttydProcesses.get(session)
467
- if (existing && !existing.proc.killed) {
468
- return {
469
- success: true,
470
- session,
471
- port: existing.port,
472
- message: 'ttyd already running for this session',
473
- already_running: true,
474
- }
475
- }
476
-
477
- // Verify tmux session exists
478
523
  try {
479
- await execAsync(`tmux has-session -t "${session}"`, {
480
- timeout: 5000,
481
- env: { ...process.env, HOME: homeDir },
482
- })
483
- } catch {
484
- reply.code(404)
485
- return {
486
- success: false,
487
- error: `tmux session '${session}' not found`,
488
- }
489
- }
490
-
491
- // Find available port and start ttyd
492
- const port = findAvailablePort()
493
-
494
- try {
495
- const proc = spawn('ttyd', [
496
- '-p', port.toString(),
497
- '-W', // Writable (allow input)
498
- '-t', 'fontSize=14',
499
- '-t', 'theme={"background": "#1a1a2e", "foreground": "#eee"}',
500
- 'tmux', 'attach', '-t', session,
501
- ], {
502
- env: { ...process.env, HOME: homeDir },
503
- stdio: ['ignore', 'pipe', 'pipe'],
504
- detached: false,
505
- })
506
-
507
- proc.on('error', (err) => {
508
- console.error(`[Terminal] ttyd error for session '${session}': ${err.message}`)
509
- ttydProcesses.delete(session)
510
- })
511
-
512
- proc.on('exit', (code) => {
513
- console.log(`[Terminal] ttyd exited for session '${session}' with code ${code}`)
514
- ttydProcesses.delete(session)
515
- })
516
-
517
- // Log stderr for debugging
518
- proc.stderr.on('data', (data) => {
519
- console.log(`[Terminal] ttyd[${session}]: ${data.toString().trim()}`)
520
- })
521
-
522
- ttydProcesses.set(session, {
523
- proc,
524
- port,
525
- startedAt: new Date().toISOString(),
526
- })
527
-
528
- console.log(`[Terminal] Started ttyd for session '${session}' on port ${port}`)
529
-
530
- // Wait a moment for ttyd to start
531
- await new Promise(resolve => setTimeout(resolve, 500))
532
-
524
+ const result = await startTtydForSession(session)
533
525
  return {
534
526
  success: true,
535
527
  session,
536
- port,
537
- message: `ttyd started on port ${port}`,
528
+ port: result.port,
529
+ message: result.alreadyRunning
530
+ ? 'ttyd already running for this session'
531
+ : `ttyd started on port ${result.port}`,
532
+ already_running: result.alreadyRunning,
538
533
  }
539
534
  } catch (error) {
540
- console.error(`[Terminal] Failed to start ttyd: ${error.message}`)
541
- reply.code(500)
542
- return {
543
- success: false,
544
- error: error.message,
545
- }
535
+ const status = error.message.includes('not found') ? 404
536
+ : error.message.includes('not installed') ? 503
537
+ : 500
538
+ reply.code(status)
539
+ return { success: false, error: error.message }
546
540
  }
547
541
  })
548
542
 
@@ -643,4 +637,6 @@ function cleanupTtyd() {
643
637
  module.exports = {
644
638
  terminalRoutes,
645
639
  cleanupTtyd,
640
+ ttydProcesses,
641
+ startTtydForSession,
646
642
  }
package/server.js CHANGED
@@ -19,6 +19,7 @@ const workflowStore = require('./workflow-store')
19
19
  const { registerRoutes, setOffline, getProcessManager, getAllowedCommands } = require('./routes')
20
20
  const { SUDO } = require('./lib/process-manager')
21
21
  const { cleanupTtyd } = require('./routes/terminal')
22
+ const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
22
23
 
23
24
  // Validate configuration before starting
24
25
  validate()
@@ -36,7 +37,8 @@ async function shutdown(signal) {
36
37
  // Stop workflow runner
37
38
  workflowRunner.stopAll()
38
39
 
39
- // Stop all ttyd processes
40
+ // Stop terminal proxy and all ttyd processes
41
+ stopTerminalProxy()
40
42
  cleanupTtyd()
41
43
 
42
44
  // Close server
@@ -116,6 +118,14 @@ async function start() {
116
118
 
117
119
  console.log(`[Server] Minion agent listening on port ${config.AGENT_PORT}`)
118
120
 
121
+ // Start terminal WebSocket proxy on port 7681
122
+ try {
123
+ await startTerminalProxy()
124
+ } catch (err) {
125
+ console.error(`[Server] Terminal proxy failed to start: ${err.message}`)
126
+ console.error('[Server] Terminal WebSocket connections will not work')
127
+ }
128
+
119
129
  // Load cached workflows and start workflow runner
120
130
  try {
121
131
  const cachedWorkflows = await workflowStore.load()
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Terminal WebSocket Proxy
3
+ *
4
+ * Listens on port 7681 and routes WebSocket connections to the correct
5
+ * ttyd instance based on the session name in the URL path.
6
+ *
7
+ * URL format: /terminal/ws/{sessionName} (via tunnel) or /ws/{sessionName} (dev)
8
+ *
9
+ * Each tmux session gets its own ttyd on a unique port (7682+).
10
+ * If ttyd is not running for a session, it is auto-started on demand.
11
+ */
12
+
13
+ const http = require('http')
14
+ const net = require('net')
15
+ const { ttydProcesses, startTtydForSession } = require('./routes/terminal')
16
+
17
+ const PROXY_PORT = 7681
18
+
19
+ /**
20
+ * Parse session name from URL path.
21
+ * Handles both /terminal/ws/{session} and /ws/{session}.
22
+ * Falls back to 'main' if no session specified.
23
+ */
24
+ function parseSessionName(url) {
25
+ const match = url?.match(/\/ws\/([^/?]+)/)
26
+ return match ? match[1] : 'main'
27
+ }
28
+
29
+ /**
30
+ * Try to connect to a local port with retries.
31
+ * ttyd may take a moment to start listening after spawn.
32
+ */
33
+ function connectWithRetry(port, retries, delay) {
34
+ return new Promise((resolve, reject) => {
35
+ function attempt(remaining) {
36
+ const conn = net.connect(port, '127.0.0.1')
37
+
38
+ conn.once('connect', () => resolve(conn))
39
+
40
+ conn.once('error', (err) => {
41
+ conn.destroy()
42
+ if (remaining > 0) {
43
+ setTimeout(() => attempt(remaining - 1), delay)
44
+ } else {
45
+ reject(err)
46
+ }
47
+ })
48
+ }
49
+
50
+ attempt(retries)
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Build the HTTP upgrade request to forward to ttyd.
56
+ * Rewrites the path to /ws (what ttyd expects) and forwards all headers.
57
+ */
58
+ function buildUpgradeRequest(req) {
59
+ let lines = [`GET /ws HTTP/${req.httpVersion}`]
60
+
61
+ // Forward all headers (ttyd needs Upgrade, Connection, Sec-WebSocket-* headers)
62
+ for (const [key, value] of Object.entries(req.headers)) {
63
+ lines.push(`${key}: ${value}`)
64
+ }
65
+
66
+ return lines.join('\r\n') + '\r\n\r\n'
67
+ }
68
+
69
+ const server = http.createServer((req, res) => {
70
+ // Only WebSocket upgrade requests are handled
71
+ res.writeHead(426, { 'Content-Type': 'text/plain' })
72
+ res.end('WebSocket connections only')
73
+ })
74
+
75
+ server.on('upgrade', async (req, socket, head) => {
76
+ const sessionName = parseSessionName(req.url)
77
+ console.log(`[Terminal Proxy] WebSocket upgrade for session '${sessionName}' (url: ${req.url})`)
78
+
79
+ // Validate session name
80
+ if (!/^[\w-]+$/.test(sessionName)) {
81
+ console.log(`[Terminal Proxy] Invalid session name: '${sessionName}'`)
82
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
83
+ return
84
+ }
85
+
86
+ // Ensure ttyd is running for this session
87
+ let entry = ttydProcesses.get(sessionName)
88
+ if (!entry || entry.proc.killed) {
89
+ try {
90
+ console.log(`[Terminal Proxy] Auto-starting ttyd for session '${sessionName}'`)
91
+ await startTtydForSession(sessionName)
92
+ entry = ttydProcesses.get(sessionName)
93
+ } catch (err) {
94
+ console.error(`[Terminal Proxy] Failed to start ttyd for '${sessionName}': ${err.message}`)
95
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
96
+ return
97
+ }
98
+ }
99
+
100
+ if (!entry) {
101
+ console.error(`[Terminal Proxy] No ttyd entry after start for '${sessionName}'`)
102
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
103
+ return
104
+ }
105
+
106
+ const targetPort = entry.port
107
+ console.log(`[Terminal Proxy] Routing '${sessionName}' to ttyd on port ${targetPort}`)
108
+
109
+ try {
110
+ // Connect to the local ttyd instance (retry up to 3 times with 300ms delay)
111
+ const target = await connectWithRetry(targetPort, 3, 300)
112
+
113
+ // Forward the upgrade request with path rewritten to /ws
114
+ const upgradeReq = buildUpgradeRequest(req)
115
+ target.write(upgradeReq)
116
+ if (head.length > 0) {
117
+ target.write(head)
118
+ }
119
+
120
+ // Pipe data bidirectionally (raw TCP after upgrade)
121
+ socket.pipe(target)
122
+ target.pipe(socket)
123
+
124
+ // Clean up on close/error
125
+ socket.on('error', () => target.destroy())
126
+ socket.on('close', () => target.destroy())
127
+ target.on('error', () => socket.destroy())
128
+ target.on('close', () => socket.destroy())
129
+ } catch (err) {
130
+ console.error(`[Terminal Proxy] Failed to connect to ttyd on port ${targetPort}: ${err.message}`)
131
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
132
+ }
133
+ })
134
+
135
+ /**
136
+ * Start the terminal WebSocket proxy server.
137
+ */
138
+ function startTerminalProxy() {
139
+ return new Promise((resolve, reject) => {
140
+ server.listen(PROXY_PORT, '0.0.0.0', () => {
141
+ console.log(`[Terminal Proxy] WebSocket proxy listening on port ${PROXY_PORT}`)
142
+ resolve()
143
+ })
144
+ server.on('error', (err) => {
145
+ console.error(`[Terminal Proxy] Failed to start: ${err.message}`)
146
+ reject(err)
147
+ })
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Stop the terminal proxy server.
153
+ */
154
+ function stopTerminalProxy() {
155
+ server.close()
156
+ }
157
+
158
+ module.exports = { startTerminalProxy, stopTerminalProxy }