@geekbeer/minion 2.67.1 → 2.68.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.67.1",
3
+ "version": "2.68.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": {
@@ -54,19 +54,114 @@ function detectProcessManager() {
54
54
  * @param {string} startCmd - Command/script block to start the agent
55
55
  * @returns {string} - Path to the generated update script (.ps1)
56
56
  */
57
- function buildUpdateScript(npmInstallCmd, stopCmd, startCmd, scriptName = 'update-agent') {
58
- const dataDir = path.join(os.homedir(), '.minion')
59
- const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
60
- const logPath = path.join(dataDir, `${scriptName}.log`)
57
+ /**
58
+ * Generate a PowerShell snippet that requests graceful shutdown via the agent's
59
+ * HTTP API, then falls back to force-kill. This mirrors Linux's SIGTERM behavior:
60
+ * the agent runs shutdown() → sends offline heartbeat → exits cleanly.
61
+ *
62
+ * Windows has no SIGTERM for console processes, so we use HTTP instead.
63
+ *
64
+ * @param {number} agentPort - The agent's HTTP port (default 8080)
65
+ * @param {string} apiToken - The agent's API token for authentication
66
+ * @returns {string} PowerShell code block for graceful shutdown
67
+ */
68
+ function buildGracefulStopBlock(agentPort, apiToken) {
69
+ return [
70
+ ` try {`,
71
+ ` Invoke-RestMethod -Uri 'http://localhost:${agentPort}/api/shutdown' -Method POST ` +
72
+ `-Headers @{ Authorization = 'Bearer ${apiToken}' } -TimeoutSec 5 | Out-Null`,
73
+ ` Log 'Graceful shutdown requested, waiting for offline heartbeat...'`,
74
+ ` Start-Sleep -Seconds 4`,
75
+ ` } catch {`,
76
+ ` Log "Graceful shutdown API failed: $_ (will force kill)"`,
77
+ ` }`,
78
+ ].join('\n')
79
+ }
61
80
 
62
- // PowerShell script content: stop → install → start, with logging
63
- // The stopCmd kills all child processes of the agent. Since this script
64
- // itself is a child of the agent (spawn from Node), we must exclude our
65
- // own PID ($PID) to avoid the update script killing itself.
66
- const safeStopCmd = stopCmd.replace(
81
+ /**
82
+ * Apply self-PID exclusion to a stopCmd so that the detached script
83
+ * does not kill itself when enumerating child processes.
84
+ */
85
+ function makeSafeStopCmd(stopCmd) {
86
+ return stopCmd.replace(
67
87
  /ForEach-Object\s*\{\s*Stop-Process/,
68
88
  'Where-Object { $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process'
69
89
  )
90
+ }
91
+
92
+ /**
93
+ * Generate a temporary PowerShell restart script that:
94
+ * 1. Requests graceful shutdown via HTTP API (sends offline heartbeat)
95
+ * 2. Force-kills remaining processes (fallback)
96
+ * 3. Restarts the agent
97
+ *
98
+ * @param {string} stopCmd - Command/script block to force-stop the agent
99
+ * @param {string} startCmd - Command/script block to start the agent
100
+ * @param {number} agentPort - The agent's HTTP port
101
+ * @param {string} apiToken - The agent's API token
102
+ * @returns {string} - Path to the generated restart script (.ps1)
103
+ */
104
+ function buildRestartScript(stopCmd, startCmd, agentPort, apiToken) {
105
+ const dataDir = path.join(os.homedir(), '.minion')
106
+ const scriptPath = path.join(dataDir, 'restart-agent.ps1')
107
+ const logPath = path.join(dataDir, 'restart-agent.log')
108
+
109
+ const safeStopCmd = makeSafeStopCmd(stopCmd)
110
+ const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
111
+
112
+ const ps1 = [
113
+ `$ErrorActionPreference = 'Stop'`,
114
+ `$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
115
+ `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
116
+ `Log 'Restart started'`,
117
+ `try {`,
118
+ ` Log 'Requesting graceful shutdown...'`,
119
+ gracefulStop,
120
+ ` Log 'Force stopping remaining processes...'`,
121
+ ` ${safeStopCmd}`,
122
+ ` Start-Sleep -Seconds 2`,
123
+ ` Log 'Starting agent...'`,
124
+ ` ${startCmd}`,
125
+ ` Log 'Restart completed successfully'`,
126
+ `} catch {`,
127
+ ` Log "Restart failed: $_"`,
128
+ ` Log 'Attempting to start agent anyway...'`,
129
+ ` ${startCmd}`,
130
+ `}`,
131
+ ].join('\n')
132
+
133
+ try { fs.mkdirSync(dataDir, { recursive: true }) } catch { /* exists */ }
134
+ fs.writeFileSync(scriptPath, ps1, 'utf-8')
135
+
136
+ return scriptPath
137
+ }
138
+
139
+ /**
140
+ * Generate a temporary PowerShell script that:
141
+ * 1. Requests graceful shutdown via HTTP API (sends offline heartbeat)
142
+ * 2. Force-kills remaining processes (releases DLL file locks)
143
+ * 3. Runs npm install -g
144
+ * 4. Restarts the agent
145
+ *
146
+ * The force-kill after graceful shutdown is necessary because node-pty's
147
+ * conpty.node DLL is locked by the running process, causing EBUSY errors
148
+ * if npm tries to overwrite it in-place.
149
+ *
150
+ * @param {string} npmInstallCmd - The npm install command to run
151
+ * @param {string} stopCmd - Command/script block to stop the agent
152
+ * @param {string} startCmd - Command/script block to start the agent
153
+ * @param {string} scriptName - Base name for the .ps1 and .log files
154
+ * @param {number} agentPort - The agent's HTTP port
155
+ * @param {string} apiToken - The agent's API token
156
+ * @returns {string} - Path to the generated update script (.ps1)
157
+ */
158
+ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd, scriptName = 'update-agent', agentPort = 8080, apiToken = '') {
159
+ const dataDir = path.join(os.homedir(), '.minion')
160
+ const scriptPath = path.join(dataDir, `${scriptName}.ps1`)
161
+ const logPath = path.join(dataDir, `${scriptName}.log`)
162
+
163
+ const safeStopCmd = makeSafeStopCmd(stopCmd)
164
+ const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
70
165
 
71
166
  const ps1 = [
72
167
  `$ErrorActionPreference = 'Stop'`,
@@ -74,7 +169,9 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd, scriptName = 'updat
74
169
  `function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
75
170
  `Log 'Update started'`,
76
171
  `try {`,
77
- ` Log 'Stopping agent...'`,
172
+ ` Log 'Requesting graceful shutdown...'`,
173
+ gracefulStop,
174
+ ` Log 'Force stopping remaining processes...'`,
78
175
  ` ${safeStopCmd}`,
79
176
  ` Start-Sleep -Seconds 3`,
80
177
  ` Log 'Installing package...'`,
@@ -103,22 +200,26 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd, scriptName = 'updat
103
200
  /**
104
201
  * Build allowed commands for the detected process manager.
105
202
  * @param {string} procMgr - Process manager type
203
+ * @param {{ AGENT_PORT?: number, API_TOKEN?: string }} [agentConfig] - Agent config for graceful shutdown
106
204
  * @returns {Record<string, { description: string; command: string; deferred?: boolean }>}
107
205
  */
108
- function buildAllowedCommands(procMgr) {
206
+ function buildAllowedCommands(procMgr, agentConfig = {}) {
109
207
  const commands = {}
208
+ const agentPort = agentConfig.AGENT_PORT || 8080
209
+ const apiToken = agentConfig.API_TOKEN || ''
110
210
 
111
211
  if (procMgr === 'user-process') {
112
212
  const dataDir = path.join(os.homedir(), '.minion')
113
213
  const pidFile = path.join(dataDir, 'minion-agent.pid')
114
214
  const startScript = path.join(dataDir, 'start-agent.ps1')
115
215
 
116
- const stopBlock = `$pid = Get-Content '${pidFile}' -ErrorAction SilentlyContinue; if ($pid) { Get-CimInstance Win32_Process -Filter \\"ParentProcessId = $pid\\" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue }`
117
- const startBlock = `Start-Process powershell -ArgumentList '-ExecutionPolicy Bypass -WindowStyle Hidden -File \\"${startScript}\\"' -WindowStyle Hidden`
216
+ const stopBlock = `$agentPid = Get-Content '${pidFile}' -ErrorAction SilentlyContinue; if ($agentPid) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $agentPid) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id $agentPid -Force -ErrorAction SilentlyContinue }`
217
+ const startBlock = `Start-Process powershell -ArgumentList ('-ExecutionPolicy Bypass -WindowStyle Hidden -File \"' + '${startScript}' + '\"') -WindowStyle Hidden`
118
218
 
119
219
  commands['restart-agent'] = {
120
220
  description: 'Restart the minion agent process',
121
- command: `powershell -Command "${stopBlock}; Start-Sleep -Seconds 2; ${startBlock}"`,
221
+ spawnArgs: ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
222
+ buildRestartScript(stopBlock, startBlock, agentPort, apiToken)]],
122
223
  deferred: true,
123
224
  }
124
225
  commands['update-agent'] = {
@@ -127,6 +228,9 @@ function buildAllowedCommands(procMgr) {
127
228
  'npm install -g @geekbeer/minion@latest',
128
229
  stopBlock,
129
230
  startBlock,
231
+ 'update-agent',
232
+ agentPort,
233
+ apiToken,
130
234
  )]],
131
235
  deferred: true,
132
236
  }
@@ -137,14 +241,17 @@ function buildAllowedCommands(procMgr) {
137
241
  stopBlock,
138
242
  startBlock,
139
243
  'update-agent-dev',
244
+ agentPort,
245
+ apiToken,
140
246
  )]],
141
247
  deferred: true,
142
248
  }
143
249
  commands['status-services'] = {
144
250
  description: 'Show agent process info',
145
- command: `powershell -Command "$pidFile = '${pidFile}'; if (Test-Path $pidFile) { $pid = (Get-Content $pidFile -Raw).Trim(); $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue; if ($proc) { Write-Host 'minion-agent: running (PID:' $pid ')' } else { Write-Host 'minion-agent: not running (stale PID)' } } else { Write-Host 'minion-agent: not running' }"`,
251
+ command: `powershell -Command "$pidFile = '${pidFile}'; if (Test-Path $pidFile) { $agentPid = (Get-Content $pidFile -Raw).Trim(); $proc = Get-Process -Id $agentPid -ErrorAction SilentlyContinue; if ($proc) { Write-Host 'minion-agent: running (PID:' $agentPid ')' } else { Write-Host 'minion-agent: not running (stale PID)' } } else { Write-Host 'minion-agent: not running' }"`,
146
252
  }
147
253
  } else if (procMgr === 'nssm') {
254
+ // NSSM handles graceful stop via its own service control, so no HTTP API needed
148
255
  commands['restart-agent'] = {
149
256
  description: 'Restart the minion agent service',
150
257
  command: 'nssm restart minion-agent',
@@ -156,6 +263,9 @@ function buildAllowedCommands(procMgr) {
156
263
  'npm install -g @geekbeer/minion@latest',
157
264
  'nssm stop minion-agent',
158
265
  'nssm start minion-agent',
266
+ 'update-agent',
267
+ agentPort,
268
+ apiToken,
159
269
  )]],
160
270
  deferred: true,
161
271
  }
@@ -166,6 +276,8 @@ function buildAllowedCommands(procMgr) {
166
276
  'nssm stop minion-agent',
167
277
  'nssm start minion-agent',
168
278
  'update-agent-dev',
279
+ agentPort,
280
+ apiToken,
169
281
  )]],
170
282
  deferred: true,
171
283
  }
@@ -178,6 +290,7 @@ function buildAllowedCommands(procMgr) {
178
290
  command: 'nssm status minion-agent & nssm status minion-websockify',
179
291
  }
180
292
  } else if (procMgr === 'sc') {
293
+ // sc.exe handles graceful stop via service control, so no HTTP API needed
181
294
  commands['restart-agent'] = {
182
295
  description: 'Restart the minion agent service',
183
296
  command: 'net stop minion-agent & net start minion-agent',
@@ -189,6 +302,9 @@ function buildAllowedCommands(procMgr) {
189
302
  'npm install -g @geekbeer/minion@latest',
190
303
  'net stop minion-agent',
191
304
  'net start minion-agent',
305
+ 'update-agent',
306
+ agentPort,
307
+ apiToken,
192
308
  )]],
193
309
  deferred: true,
194
310
  }
@@ -199,6 +315,8 @@ function buildAllowedCommands(procMgr) {
199
315
  'net stop minion-agent',
200
316
  'net start minion-agent',
201
317
  'update-agent-dev',
318
+ agentPort,
319
+ apiToken,
202
320
  )]],
203
321
  deferred: true,
204
322
  }
@@ -196,10 +196,27 @@ function Stop-MinionProcess {
196
196
  $agentPid = (Get-Content $PidFile -Raw).Trim()
197
197
  $proc = Get-Process -Id $agentPid -ErrorAction SilentlyContinue
198
198
  if ($proc) {
199
- # Also stop child processes (node, websockify, cloudflared)
199
+ # Graceful shutdown via HTTP API (sends offline heartbeat to HQ)
200
+ # This mirrors Linux's SIGTERM → shutdown() flow.
201
+ $token = ''
202
+ if (Test-Path $EnvFile) {
203
+ $envVars = Read-EnvFile $EnvFile
204
+ if ($envVars['API_TOKEN']) { $token = $envVars['API_TOKEN'] }
205
+ }
206
+ try {
207
+ $headers = @{}
208
+ if ($token) { $headers['Authorization'] = "Bearer $token" }
209
+ Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST -Headers $headers -TimeoutSec 5 | Out-Null
210
+ Write-Host "Graceful shutdown requested, waiting..."
211
+ Start-Sleep -Seconds 4
212
+ } catch {
213
+ Write-Host "Graceful shutdown failed, force stopping..."
214
+ }
215
+
216
+ # Force kill remaining processes (node, websockify, cloudflared)
200
217
  Get-CimInstance Win32_Process -Filter "ParentProcessId = $agentPid" -ErrorAction SilentlyContinue |
201
218
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
202
- Stop-Process -Id $agentPid -Force
219
+ Stop-Process -Id $agentPid -Force -ErrorAction SilentlyContinue
203
220
  Write-Host "minion-agent stopped (PID: $agentPid)"
204
221
  } else {
205
222
  Write-Host "minion-agent was not running (stale PID file)"
@@ -9,10 +9,11 @@ const { promisify } = require('util')
9
9
  const execAsync = promisify(exec)
10
10
 
11
11
  const { verifyToken } = require('../../core/lib/auth')
12
+ const { config } = require('../../core/config')
12
13
  const { detectProcessManager, buildAllowedCommands } = require('../lib/process-manager')
13
14
 
14
15
  const PROC_MGR = detectProcessManager()
15
- const ALLOWED_COMMANDS = buildAllowedCommands(PROC_MGR)
16
+ const ALLOWED_COMMANDS = buildAllowedCommands(PROC_MGR, config)
16
17
 
17
18
  async function commandRoutes(fastify) {
18
19
  fastify.get('/api/commands', async (request, reply) => {
package/win/server.js CHANGED
@@ -38,6 +38,7 @@ const revisionWatcher = require('../core/lib/revision-watcher')
38
38
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
39
39
  const threadWatcher = require('../core/lib/thread-watcher')
40
40
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
41
+ const { verifyToken } = require('../core/lib/auth')
41
42
  const { terminalRoutes, cleanupSessions } = require('./routes/terminal')
42
43
  const { startTerminalServer, stopTerminalServer } = require('./terminal-server')
43
44
  const { fileRoutes } = require('./routes/files')
@@ -214,6 +215,19 @@ async function registerRoutes(app) {
214
215
  await app.register(helpThreadRoutes)
215
216
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
216
217
 
218
+ // Shutdown endpoint — allows detached restart/update scripts to trigger
219
+ // graceful shutdown (offline heartbeat) before force-killing the process.
220
+ // Windows has no SIGTERM equivalent for console processes, so this HTTP API
221
+ // serves the same role that SIGTERM plays on Linux.
222
+ app.post('/api/shutdown', async (request, reply) => {
223
+ if (!verifyToken(request)) {
224
+ reply.code(401)
225
+ return { success: false, error: 'Unauthorized' }
226
+ }
227
+ reply.send({ success: true })
228
+ setImmediate(() => shutdown('API'))
229
+ })
230
+
217
231
  // Windows-specific routes
218
232
  await app.register(commandRoutes)
219
233
  await app.register(terminalRoutes)