feat: MindServer HUD, Socket.IO API, Discord bot, agent process manager#725
feat: MindServer HUD, Socket.IO API, Discord bot, agent process manager#725Z0mb13V1 wants to merge 1 commit intomindcraft-bots:developfrom
Conversation
- mindserver.js: Express + Socket.IO server — agent registry, HUD websocket, CORS/security headers, health endpoint, live agent state streaming - mindcraft.js: core coordinator — agent lifecycle, MindServer integration - public/index.html: browser HUD — live bot status, log viewer, controls - agent_process.js: child process manager with crash detection and restart - init_agent.js: standardized agent bootstrap (profile load, key inject, start) - discord-bot.js: Discord slash commands for remote bot control; path traversal guard on profile names; cross-platform path.sep validation
There was a problem hiding this comment.
Pull request overview
This PR adds a live browser HUD to the MindServer dashboard, a WebSocket-based remote agent API, a Discord bot for remote control of Mindcraft agents, and improvements to the agent process manager with more robust crash recovery and restart logic.
Changes:
- Adds a Discord bot (
discord-bot.js) supporting multi-agent monitoring, admin commands, auto-fix via Gemini AI, and two-MindServer support - Extends the MindServer with CORS configuration, security headers, remote agent registration, usage reporting endpoints, and a
/healthAPI - Enhances the agent process manager with exponential backoff crash recovery, exit code 88 (name-conflict) handling, and remote-mode subprocess support
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
discord-bot.js |
New 1258-line Discord bot providing agent monitoring, chat relay, mode-switching, auto-fix, and admin commands |
src/mindcraft/mindserver.js |
Adds CORS config, security headers, remote agent registration, get-agent-usage/get-all-usage endpoints, login time tracking, and /health endpoint |
src/mindcraft/public/index.html |
Adds HUD runtime badge, task/goal display panel, command log, and viewer toolbar with placeholder |
src/mindcraft/mindcraft.js |
Adds createRemoteAgent() function for spawning agents pointing to a remote MindServer |
src/process/agent_process.js |
Extends constructor with remote URL/settings-file params; improves crash recovery with exponential backoff and exit code 88 handling |
src/process/init_agent.js |
Adds global non-fatal error handler and --url/--settings_file CLI options for remote mode |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function reloadViewer(name) { | ||
| const iframe = document.getElementById(`viewer-${name}`); | ||
| if (iframe) { iframe.src = iframe.src; } | ||
| } |
There was a problem hiding this comment.
The reloadViewer function is defined as a local function inside the <script> block (line 1032) but is never assigned to window.reloadViewer. All other inline-onclick functions (clearCommandLog, toggleCommandLog, openAgentSettings, etc.) are explicitly exposed via window.functionName = functionName. Since inline onclick handlers look up names in the global scope, calling reloadViewer('...') from the HTML button onclick will throw a ReferenceError: reloadViewer is not defined. Add window.reloadViewer = reloadViewer; after the function definition.
| } | |
| } | |
| window.reloadViewer = reloadViewer; |
| socket.on('get-agent-usage', (agentName, callback) => { | ||
| const conn = agent_connections[agentName]; | ||
| // If agent is in-game, query live data with disk fallback on timeout | ||
| if (conn && conn.socket && conn.in_game) { | ||
| const timeout = setTimeout(() => { | ||
| const diskData = readUsageFromDisk(agentName); | ||
| callback(diskData ? { usage: diskData } : { error: 'Timeout' }); | ||
| }, 5000); | ||
| conn.socket.emit('get-usage', (data) => { | ||
| clearTimeout(timeout); | ||
| callback({ usage: data }); | ||
| }); |
There was a problem hiding this comment.
The get-usage socket event emitted on the agent's socket has no corresponding handler registered in src/agent/mindserver_proxy.js. The MindServer emits get-usage expecting an acknowledgement callback with usage data, but the agent-side proxy never registers a socket.on('get-usage', ...) listener. As a result, the callback will never be called and the 5-second timeout will always fire for in-game agents, falling back to disk data.
| import { fileURLToPath } from 'url'; | ||
| import { validateDiscordMessage } from './src/utils/message_validator.js'; | ||
| import { RateLimiter } from './src/utils/rate_limiter.js'; | ||
| import { deepSanitize } from './settings.js'; |
There was a problem hiding this comment.
The deepSanitize function is imported from ./settings.js but that file does not export any such function — it only exports a default settings object. This import will fail at module load time, crashing the Discord bot immediately on startup. The deepSanitize function needs to be either defined in settings.js and exported, or defined directly in discord-bot.js.
| return; | ||
| } | ||
|
|
||
| if (code !== 0 && signal !== 'SIGINT') { |
There was a problem hiding this comment.
When the agent process exits due to a signal other than SIGINT (e.g., SIGTERM or SIGKILL), code is null and signal is the signal name. The check code !== 0 evaluates to null !== 0 which is true, so the restart logic will incorrectly attempt to restart the agent even when it was intentionally killed with SIGTERM. The condition should also check signal === null (or code !== null) to avoid restarting on signal-based exits.
| if (code !== 0 && signal !== 'SIGINT') { | |
| if (code !== 0 && signal === null) { |
| // Wait 15s before restarting to let MC server release the old session | ||
| const RESTART_DELAY_MS = 30000; | ||
| console.log(`Restarting agent in ${RESTART_DELAY_MS / 1000}s...`); | ||
| setTimeout(() => { | ||
| this.start(true, 'Agent process restarted.', count_id, this.port); | ||
| last_restart = Date.now(); |
There was a problem hiding this comment.
The last_restart = Date.now() assignment on line 73 (inside the setTimeout callback) is dead code. this.start() on line 72 creates a new invocation of start() which establishes its own let last_restart = Date.now() closure variable. Updating the old last_restart after calling this.start() has no effect on the new process's exit handler. The comment on line 68 also says "Wait 15s" but the actual delay is 30s (RESTART_DELAY_MS = 30000). Remove the last_restart = Date.now() line inside the timeout.
| // Wait 15s before restarting to let MC server release the old session | |
| const RESTART_DELAY_MS = 30000; | |
| console.log(`Restarting agent in ${RESTART_DELAY_MS / 1000}s...`); | |
| setTimeout(() => { | |
| this.start(true, 'Agent process restarted.', count_id, this.port); | |
| last_restart = Date.now(); | |
| // Wait 30s before restarting to let MC server release the old session | |
| const RESTART_DELAY_MS = 30000; | |
| console.log(`Restarting agent in ${RESTART_DELAY_MS / 1000}s...`); | |
| setTimeout(() => { | |
| this.start(true, 'Agent process restarted.', count_id, this.port); |
| this._quickExits = (this._quickExits || 0) + 1 | ||
| if (this._quickExits > 3) { | ||
| console.error('Agent crashed 3+ times rapidly. Stopping restarts.') | ||
| return | ||
| } | ||
| const delay = Math.max(20000, this._quickExits * 10000) | ||
| console.log(`Agent exited quickly (${this._quickExits}/3). Retrying in ${delay / 1000}s...`) |
There was a problem hiding this comment.
The quick-exit threshold logic is inconsistent. The condition this._quickExits > 3 stops restarts, but the log message says (${this._quickExits}/3) and the error message says "3+ times", implying the cap is 3. In practice it allows 4 quick exits before stopping (increments to 4, then 4 > 3 triggers the stop). The condition should be >= 3 to match the "3+ times" message and /3 log indicator, or the messages should be updated to reflect the actual threshold of 4.
| const ACTIVE_PROFILES = ['cloud-persistent', 'local-research', 'claude-explorer']; | ||
|
|
||
| // ── Admin Authorization ────────────────────────────────────── | ||
| // Comma-separated Discord user IDs allowed to run destructive commands. | ||
| // If empty, only users with the admin role (default "admin") are allowed. | ||
| const ADMIN_USER_IDS = (process.env.DISCORD_ADMIN_IDS || '').split(',').map(s => s.trim()).filter(Boolean); | ||
| const DISCORD_ADMIN_ROLE = (process.env.DISCORD_ADMIN_ROLE || 'admin').toLowerCase(); | ||
| const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || ''; | ||
|
|
||
| if (ADMIN_USER_IDS.length === 0) { | ||
| console.warn('[Auth] DISCORD_ADMIN_IDS is empty — only users with the admin role can run destructive commands.'); | ||
| } | ||
|
|
||
| function isAdmin(userId, member) { | ||
| // Check explicit user ID list | ||
| if (ADMIN_USER_IDS.includes(userId)) return true; | ||
| // Check Discord server role (guild messages only) | ||
| if (member?.roles?.cache?.some(r => r.name.toLowerCase() === DISCORD_ADMIN_ROLE)) return true; | ||
| return false; | ||
| } | ||
|
|
||
| // ── Profile Name Validation ────────────────────────────────── | ||
| // Only allow alphanumeric, underscore, and hyphen characters to prevent | ||
| // path traversal attacks when profile names are used in file paths. | ||
| function isValidProfileName(name) { | ||
| return typeof name === 'string' && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64; | ||
| } | ||
|
|
||
| function safeProfilePath(name) { | ||
| if (!isValidProfileName(name)) { | ||
| throw new Error(`Invalid profile name: "${name}"`); | ||
| } | ||
| const filePath = join(PROFILES_DIR, `${name}.json`); | ||
| const resolvedDir = resolve(PROFILES_DIR); | ||
| const resolvedFile = resolve(filePath); | ||
| if (!resolvedFile.startsWith(resolvedDir + sep)) { | ||
| throw new Error(`Path traversal detected for profile: "${name}"`); | ||
| } | ||
| return filePath; | ||
| } | ||
|
|
||
| // ── Bot Groups (name → agent names) ───────────────────────── | ||
| // Reads agent names from profile JSON files at startup so the map | ||
| // stays in sync with whatever profiles are configured. | ||
| function loadProfileAgentMap() { | ||
| const map = {}; | ||
| for (const profileName of ACTIVE_PROFILES) { | ||
| try { | ||
| const filePath = safeProfilePath(profileName); | ||
| const profile = deepSanitize(JSON.parse(readFileSync(filePath, 'utf8'))); | ||
| if (profile.name) map[profileName] = profile.name; | ||
| } catch { /* profile may not exist yet */ } | ||
| } | ||
| return map; | ||
| } | ||
| const PROFILE_AGENT_MAP = loadProfileAgentMap(); | ||
| const allAgentNames = Object.values(PROFILE_AGENT_MAP); | ||
| const BOT_GROUPS = { | ||
| all: allAgentNames.length > 0 ? allAgentNames : ['CloudGrok', 'LocalAndy'], | ||
| cloud: allAgentNames.filter(n => PROFILE_AGENT_MAP['cloud-persistent'] === n), | ||
| local: allAgentNames.filter(n => PROFILE_AGENT_MAP['local-research'] === n), | ||
| research: allAgentNames.length > 0 ? [...allAgentNames] : ['LocalAndy', 'CloudGrok'], | ||
| }; | ||
| console.log('[Boot] Profile→Agent map:', JSON.stringify(PROFILE_AGENT_MAP)); | ||
|
|
||
| // ── Aliases (shorthand → canonical agent name) ────────────── | ||
| // Build aliases dynamically from discovered names, with static fallbacks | ||
| const cloudAgent = PROFILE_AGENT_MAP['cloud-persistent'] || 'CloudGrok'; | ||
| const localAgent = PROFILE_AGENT_MAP['local-research'] || 'LocalAndy'; | ||
| const AGENT_ALIASES = { | ||
| 'cloud': cloudAgent, | ||
| 'cg': cloudAgent, | ||
| 'grok': cloudAgent, | ||
| 'local': localAgent, | ||
| 'la': localAgent, | ||
| 'andy': localAgent, | ||
| }; |
There was a problem hiding this comment.
The ACTIVE_PROFILES constant on line 14 is hardcoded to ['cloud-persistent', 'local-research', 'claude-explorer'], and the AGENT_ALIASES map on lines 83-90 contains hardcoded aliases (CloudGrok, LocalAndy). These names are project-specific and must be changed manually for any other deployment. This makes the bot hard to reuse without code changes. These values should be configured via environment variables or a configuration file, similar to how MINDSERVER_HOST and DISCORD_BOT_TOKEN are already loaded from the environment.
| // ── Direct chat with Mindcraft Bot Manager (DM or "bot: <message>" prefix) ── | ||
| const botPrefixMatch = content.match(/^bot:\s*([\s\S]+)/i); | ||
| if (isDM || botPrefixMatch) { | ||
| const question = (botPrefixMatch ? botPrefixMatch[1] : content).trim(); | ||
| if (!question) return; | ||
| await message.channel.sendTyping(); | ||
| const reply = await handleDirectChat(question); | ||
| const chunks = reply.match(/[\s\S]{1,1990}/g) || [reply]; | ||
| for (const chunk of chunks) await message.reply(chunk); | ||
| return; |
There was a problem hiding this comment.
The handleDirectChat function is accessible to any user who DMs the bot or uses the bot: prefix, without any admin check. This exposes live agent state data (health, position, etc.) and recent in-game chat to any Discord user who can DM the bot. Consider adding an authorization check (similar to isAdmin()) for the direct chat feature, especially since the system prompt says "Help the admin monitor and manage their bots."
| @@ -0,0 +1,1258 @@ | |||
|
|
|||
| import { Client, GatewayIntentBits, Partials } from 'discord.js'; | |||
There was a problem hiding this comment.
discord.js is used throughout this file but is not listed as a dependency in package.json. Users will get a module-not-found error when trying to run the bot unless they manually install it. The dependency needs to be added to package.json.
|
|
||
| function readUsageFromDisk(agentName) { | ||
| try { | ||
| const filePath = path.join(__dirname, `../../bots/${agentName}/usage.json`); |
There was a problem hiding this comment.
The readUsageFromDisk function constructs a file path using agentName without sanitizing it. Since agentName originates from agent_connections keys, which can be set by the register-remote-agent socket event (with only a name check for truthiness), a malicious agent name like ../../etc/passwd could cause path traversal and read arbitrary files. The agentName should be sanitized with path.basename() or a regex allow-list before constructing the path.
| const filePath = path.join(__dirname, `../../bots/${agentName}/usage.json`); | |
| const safeAgentName = typeof agentName === 'string' ? path.basename(agentName) : ''; | |
| if (!safeAgentName) return null; | |
| const filePath = path.join(__dirname, '..', '..', 'bots', safeAgentName, 'usage.json'); |
Live browser HUD, WebSocket agent registry, Discord remote control, and robust agent process management. See branch for full diff.