@geekbeer/minion 2.67.0 → 2.68.0

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.0",
3
+ "version": "2.68.0",
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) {
58
- const dataDir = path.join(os.homedir(), '.minion')
59
- const scriptPath = path.join(dataDir, 'update-agent.ps1')
60
- const logPath = path.join(dataDir, 'update-agent.log')
61
-
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(
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
+ }
80
+
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) {
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,10 +200,13 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd) {
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')
@@ -118,7 +218,8 @@ function buildAllowedCommands(procMgr) {
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
  }
@@ -136,6 +240,9 @@ function buildAllowedCommands(procMgr) {
136
240
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
137
241
  stopBlock,
138
242
  startBlock,
243
+ 'update-agent-dev',
244
+ agentPort,
245
+ apiToken,
139
246
  )]],
140
247
  deferred: true,
141
248
  }
@@ -144,6 +251,7 @@ function buildAllowedCommands(procMgr) {
144
251
  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' }"`,
145
252
  }
146
253
  } else if (procMgr === 'nssm') {
254
+ // NSSM handles graceful stop via its own service control, so no HTTP API needed
147
255
  commands['restart-agent'] = {
148
256
  description: 'Restart the minion agent service',
149
257
  command: 'nssm restart minion-agent',
@@ -155,6 +263,9 @@ function buildAllowedCommands(procMgr) {
155
263
  'npm install -g @geekbeer/minion@latest',
156
264
  'nssm stop minion-agent',
157
265
  'nssm start minion-agent',
266
+ 'update-agent',
267
+ agentPort,
268
+ apiToken,
158
269
  )]],
159
270
  deferred: true,
160
271
  }
@@ -164,6 +275,9 @@ function buildAllowedCommands(procMgr) {
164
275
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
165
276
  'nssm stop minion-agent',
166
277
  'nssm start minion-agent',
278
+ 'update-agent-dev',
279
+ agentPort,
280
+ apiToken,
167
281
  )]],
168
282
  deferred: true,
169
283
  }
@@ -176,6 +290,7 @@ function buildAllowedCommands(procMgr) {
176
290
  command: 'nssm status minion-agent & nssm status minion-websockify',
177
291
  }
178
292
  } else if (procMgr === 'sc') {
293
+ // sc.exe handles graceful stop via service control, so no HTTP API needed
179
294
  commands['restart-agent'] = {
180
295
  description: 'Restart the minion agent service',
181
296
  command: 'net stop minion-agent & net start minion-agent',
@@ -187,6 +302,9 @@ function buildAllowedCommands(procMgr) {
187
302
  'npm install -g @geekbeer/minion@latest',
188
303
  'net stop minion-agent',
189
304
  'net start minion-agent',
305
+ 'update-agent',
306
+ agentPort,
307
+ apiToken,
190
308
  )]],
191
309
  deferred: true,
192
310
  }
@@ -196,6 +314,9 @@ function buildAllowedCommands(procMgr) {
196
314
  'npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873',
197
315
  'net stop minion-agent',
198
316
  'net start minion-agent',
317
+ 'update-agent-dev',
318
+ agentPort,
319
+ apiToken,
199
320
  )]],
200
321
  deferred: true,
201
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)