nothumanallowed 15.1.9 → 15.1.10

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": "nothumanallowed",
3
- "version": "15.1.9",
3
+ "version": "15.1.10",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,11 +84,42 @@ export async function cmdUI(args) {
84
84
  }
85
85
  } catch { /* port is free */ }
86
86
 
87
- // Auto-start ops daemon (Telegram + cron) if not already running
87
+ // Detect if we were spawned by the self-restart helper after an npm-update.
88
+ // The marker file is written by /api/npm-update right before exiting.
89
+ // This lets us:
90
+ // - confirm to the user that the upgrade landed (visible in the boot log)
91
+ // - guarantee the ops daemon (Telegram/Discord bot) is back up
92
+ let restartedFromUpdate = false;
93
+ try {
94
+ const fs = await import('fs');
95
+ const path = await import('path');
96
+ const os = await import('os');
97
+ const markerPath = path.join(os.default.homedir(), '.nha', 'last-restart.json');
98
+ if (fs.existsSync(markerPath)) {
99
+ const stat = fs.statSync(markerPath);
100
+ // Consider the marker fresh only if written within the last 5 minutes
101
+ if (Date.now() - stat.mtimeMs < 5 * 60_000) {
102
+ const meta = JSON.parse(fs.readFileSync(markerPath, 'utf-8'));
103
+ restartedFromUpdate = true;
104
+ console.log(` \x1b[0;32m↻\x1b[0m Restarted after update: v${meta.fromVersion} → v${meta.toVersion}`);
105
+ }
106
+ // Always clean up so we don't repeat the message on next manual restart
107
+ try { fs.unlinkSync(markerPath); } catch {}
108
+ }
109
+ } catch { /* non-fatal */ }
110
+
111
+ // Auto-start ops daemon (Telegram + cron) if not already running.
112
+ // After a self-restart this is what brings the Telegram bot back online —
113
+ // critical for VM users whose only control channel is Telegram.
88
114
  if (!isRunning()) {
89
115
  const result = startDaemon();
90
116
  if (result.ok) {
91
- console.log(` \x1b[0;32m✓\x1b[0m PAO daemon started (PID ${result.pid})`);
117
+ console.log(` \x1b[0;32m✓\x1b[0m PAO daemon ${restartedFromUpdate ? 'restarted' : 'started'} (PID ${result.pid})`);
118
+ } else {
119
+ console.log(` \x1b[0;31m✗\x1b[0m PAO daemon failed to start: ${result.error || 'unknown'}`);
120
+ if (restartedFromUpdate) {
121
+ console.log(` Telegram/Discord bot may be offline. Run "nha ops start" manually.`);
122
+ }
92
123
  }
93
124
  }
94
125
 
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '15.1.9';
8
+ export const VERSION = '15.1.10';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -376,15 +376,40 @@ export async function startServer({ port = 3847, host = '127.0.0.1', noBrowser =
376
376
 
377
377
  const server = http.createServer(handleRequest);
378
378
 
379
- // Attach WebSocket AFTER HTTP server is listening to avoid WS error events
380
- await new Promise((resolve, reject) => {
381
- server.once('error', reject);
379
+ // Retry the bind for up to 12 s on EADDRINUSE. The classic case is a fresh
380
+ // self-restart right after the previous process died: the listen socket can
381
+ // be stuck in TIME_WAIT (no PID owns it, so the lsof-kill above can't help).
382
+ // Linear backoff 600 ms × 20 = 12 s — more than enough on every kernel I've
383
+ // seen for a localhost listen socket to clear.
384
+ const tryListen = () => new Promise((resolve, reject) => {
385
+ const onErr = (err) => reject(err);
386
+ server.once('error', onErr);
382
387
  server.listen(port, host, () => {
383
- server.removeListener('error', reject);
388
+ server.removeListener('error', onErr);
384
389
  resolve(undefined);
385
390
  });
386
391
  });
387
392
 
393
+ let listenAttempts = 0;
394
+ const MAX_LISTEN_ATTEMPTS = 20;
395
+ while (true) {
396
+ try {
397
+ await tryListen();
398
+ break;
399
+ } catch (err) {
400
+ listenAttempts++;
401
+ const isAddrInUse = err && (err.code === 'EADDRINUSE' || err.code === 'EACCES');
402
+ if (!isAddrInUse || listenAttempts >= MAX_LISTEN_ATTEMPTS) {
403
+ throw err;
404
+ }
405
+ // Brief notice every 3 s so the user knows we're not stuck
406
+ if (listenAttempts % 5 === 0) {
407
+ console.log(`\x1b[33m ! Port ${port} still busy (TIME_WAIT?), retry ${listenAttempts}/${MAX_LISTEN_ATTEMPTS}...\x1b[0m`);
408
+ }
409
+ await new Promise((r) => setTimeout(r, 600));
410
+ }
411
+ }
412
+
388
413
  setupWebSocket(server);
389
414
 
390
415
  const G = '\x1b[0;32m', NC = '\x1b[0m', D = '\x1b[2m', BOLD = '\x1b[1m', Y = '\x1b[33m', R = '\x1b[31m';
@@ -146,10 +146,17 @@ export function register(router) {
146
146
  }
147
147
  } catch { /* ignore */ }
148
148
 
149
- // Stop the ops daemon if it's running it must reconnect with new code
149
+ // Remember if the ops daemon (Telegram/Discord bot) was running so the
150
+ // newly-spawned server can restart it. Without this, a VM user who
151
+ // controls everything via Telegram would lose the bot until they ran
152
+ // `nha ops start` manually.
153
+ let daemonWasRunning = false;
150
154
  try {
151
155
  const { isRunning, stopDaemon } = await import('../../services/ops-daemon.mjs');
152
- if (isRunning()) await stopDaemon();
156
+ if (isRunning()) {
157
+ daemonWasRunning = true;
158
+ await stopDaemon();
159
+ }
153
160
  } catch { /* ignore */ }
154
161
 
155
162
  // Send the response NOW, before we kick off the restart. The client
@@ -166,62 +173,87 @@ export function register(router) {
166
173
  });
167
174
 
168
175
  // ── Schedule the self-restart ──
169
- // We do this OUTSIDE the response cycle: a detached child that survives
170
- // our death, watches our PID, then re-spawns with the same argv.
171
- // On POSIX we use `sh -c`. On Windows we use a detached spawn directly
172
- // because cmd.exe handles the "wait for PID" idiom differently.
173
176
  const parentPid = process.pid;
174
- const respawnNode = process.execPath; // absolute path to node
175
- const respawnArgs = process.argv.slice(1); // [cli.mjs, "ui", ...]
176
- const argvJson = JSON.stringify({ node: respawnNode, args: respawnArgs, parentPid });
177
+ const respawnNode = process.execPath;
178
+ const respawnArgs = process.argv.slice(1);
179
+ const home = os.homedir();
180
+ const nhaDir = path.join(home, '.nha');
181
+ if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
182
+
183
+ // Write the restart marker BEFORE spawning the helper. The new server
184
+ // reads this on boot to know:
185
+ // - the update happened (so it can show "Updated to v…" on first load)
186
+ // - whether the ops daemon (Telegram bot) was running and must be restarted
187
+ const markerPath = path.join(nhaDir, 'last-restart.json');
188
+ try {
189
+ fs.writeFileSync(
190
+ markerPath,
191
+ JSON.stringify({
192
+ at: new Date().toISOString(),
193
+ fromVersion: VERSION,
194
+ toVersion: newVersion,
195
+ daemonWasRunning,
196
+ parentPid,
197
+ }, null, 2),
198
+ );
199
+ } catch { /* non-fatal */ }
200
+
201
+ // Open log file for the detached child — if anything goes wrong on boot
202
+ // we want the user to be able to read why instead of staring at a black
203
+ // screen. Append mode so previous restart logs survive.
204
+ const logPath = path.join(nhaDir, 'server.log');
205
+ let logFd;
206
+ try {
207
+ logFd = fs.openSync(logPath, 'a');
208
+ fs.writeSync(logFd, `\n[${new Date().toISOString()}] === Restart helper spawning. Parent PID: ${parentPid}. Target version: ${newVersion} ===\n`);
209
+ } catch { /* will fall back to 'ignore' */ }
177
210
 
178
211
  setTimeout(() => {
179
212
  try {
180
213
  if (process.platform === 'win32') {
181
- // Windows: schedule via PowerShell wait-process then start the new node.
182
- const psScript = `
183
- try { Wait-Process -Id ${parentPid} -Timeout 30 } catch {};
184
- Start-Sleep -Milliseconds 1200;
185
- Start-Process -FilePath "${respawnNode.replace(/\\/g, '\\\\')}" -ArgumentList ${JSON.stringify(respawnArgs).replace(/"/g, '\\"')} -WindowStyle Hidden
186
- `.replace(/\s+/g, ' ').trim();
214
+ // Windows: PowerShell waits for the parent to die, frees the port,
215
+ // then starts node hidden. Output is redirected to server.log.
216
+ const psScript = [
217
+ `try { Wait-Process -Id ${parentPid} -Timeout 30 } catch {};`,
218
+ `Start-Sleep -Milliseconds 1500;`,
219
+ `$logPath = "${logPath.replace(/\\/g, '\\\\')}";`,
220
+ `Start-Process -FilePath "${respawnNode.replace(/\\/g, '\\\\')}"`,
221
+ ` -ArgumentList ${JSON.stringify(respawnArgs).replace(/"/g, '\\"')}`,
222
+ ` -RedirectStandardOutput $logPath -RedirectStandardError $logPath`,
223
+ ` -WindowStyle Hidden`,
224
+ ].join('').replace(/\s+/g, ' ').trim();
187
225
  spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
188
226
  detached: true,
189
227
  stdio: 'ignore',
190
228
  windowsHide: true,
191
229
  }).unref();
192
230
  } else {
193
- // POSIX: shell loop that polls `kill -0 PID`, then re-exec the cmd.
194
- // Using exec replaces the shell process so we don't leave a wrapper.
195
- const escapedArgs = respawnArgs.map(a => `'${a.replace(/'/g, `'\\''`)}'`).join(' ');
196
- const escapedNode = `'${respawnNode.replace(/'/g, `'\\''`)}'`;
231
+ // POSIX: poll for parent death (max ~12s), then wait an extra 2s
232
+ // for TIME_WAIT to clear on the listen socket, then exec node
233
+ // with stdio redirected to the log file.
234
+ const escArg = a => `'${a.replace(/'/g, `'\\''`)}'`;
235
+ const escapedArgs = respawnArgs.map(escArg).join(' ');
236
+ const escapedNode = escArg(respawnNode);
237
+ const escapedLog = escArg(logPath);
197
238
  const sh = `
198
239
  i=0
199
240
  while kill -0 ${parentPid} 2>/dev/null && [ $i -lt 60 ]; do
200
241
  sleep 0.2
201
242
  i=$((i+1))
202
243
  done
203
- sleep 1
204
- exec ${escapedNode} ${escapedArgs}
244
+ # Extra wait: lets TIME_WAIT on the listen socket release and
245
+ # gives the kernel a moment to fully reap the parent.
246
+ sleep 2
247
+ exec ${escapedNode} ${escapedArgs} >> ${escapedLog} 2>&1
205
248
  `;
249
+ const childStdio = logFd ? ['ignore', logFd, logFd] : 'ignore';
206
250
  spawn('sh', ['-c', sh], {
207
251
  detached: true,
208
- stdio: 'ignore',
252
+ stdio: childStdio,
209
253
  }).unref();
210
254
  }
211
-
212
- // Mark the restart so the next process knows it was self-spawned
213
- try {
214
- const home = os.homedir();
215
- const nhaDir = path.join(home, '.nha');
216
- if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
217
- fs.writeFileSync(
218
- path.join(nhaDir, 'last-restart.json'),
219
- JSON.stringify({ at: new Date().toISOString(), fromVersion: VERSION, toVersion: newVersion, meta: argvJson }, null, 2),
220
- );
221
- } catch { /* non-fatal */ }
222
255
  } catch { /* spawn failed — user will need to restart manually */ }
223
256
 
224
- // Give the response a moment, then exit so the helper can take over.
225
257
  setTimeout(() => process.exit(0), 200);
226
258
  }, 600);
227
259
  } catch (e) {