${userHtml}
@@ -928,49 +905,6 @@ class ConversationPanel {
return `
${parts.join('')}
`
}
- // --- Context status bar ---
-
- renderContextStatusBar(activeContext, archivedContexts, contextHistoryMessage) {
- const parts = []
- // Active context indicator
- if (activeContext?.label) {
- parts.push(`
${escapeHtml(activeContext.label)} (${activeContext.messageCount} msgs)`)
- }
- else {
- parts.push('
No active context')
- }
-
- // Archived count
- if (archivedContexts?.length > 0) {
- const archId = `cv-archived-${Math.random().toString(36).slice(2, 6)}`
- const items = archivedContexts.map((ctx, i) => {
- const time = new Date(ctx.archivedAt).toLocaleTimeString()
- return `
-
#${i + 1}
-
${escapeHtml(ctx.label || 'unnamed')}
-
${ctx.turns}t · ${time}
-
${escapeHtml(ctx.summary)}
-
`
- }).join('')
- parts.push(`
- \u25B6 ${archivedContexts.length} archived
- `)
- // Append the collapsible body after the status bar
- parts.push(`
${items}
`)
- }
-
- // Context history prefix
- if (contextHistoryMessage) {
- const chId = `cv-ctxhist-${Math.random().toString(36).slice(2, 6)}`
- parts.push(`
- \u25B6 prefix
- `)
- parts.push(`
${escapeHtml(contextHistoryMessage)} `)
- }
-
- return `
${parts.join('')}
`
- }
-
// --- System message ---
renderSystemMessage(msg) {
diff --git a/services/minecraft/src/main.ts b/services/minecraft/src/main.ts
index 66ac832a2..b7e716141 100644
--- a/services/minecraft/src/main.ts
+++ b/services/minecraft/src/main.ts
@@ -9,12 +9,14 @@ import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder'
import { plugin as MineflayerPVP } from 'mineflayer-pvp'
import { plugin as MineflayerTool } from 'mineflayer-tool'
+import { startAiriClientConnection } from './airi/start-background-client'
import { CognitiveEngine } from './cognitive'
-import { initBot } from './composables/bot'
import { config, initEnv } from './composables/config'
+import { MinecraftRuntimeConfigManager } from './composables/runtime-config'
import { DebugService } from './debug'
import { setupMineflayerViewer } from './debug/mineflayer-viewer'
-import { wrapPlugin } from './libs/mineflayer'
+import { Mineflayer, wrapPlugin } from './libs/mineflayer'
+import { MinecraftBotRuntime } from './minecraft-bot-runtime'
import { initLogger, useLogger } from './utils/logger'
// ...
@@ -22,9 +24,32 @@ import { initLogger, useLogger } from './utils/logger'
async function main() {
initLogger() // todo: save logs to file
initEnv()
+ const logger = useLogger()
+
+ const runtimeConfigManager = new MinecraftRuntimeConfigManager()
+ const configManager = {
+ load: () => {
+ const snapshot = runtimeConfigManager.load()
+ config.bot = {
+ ...config.bot,
+ ...snapshot.effectiveBotConfig,
+ }
+ return snapshot
+ },
+ save: (editableConfig: Parameters
[0]) => {
+ const snapshot = runtimeConfigManager.save(editableConfig)
+ config.bot = {
+ ...config.bot,
+ ...snapshot.effectiveBotConfig,
+ }
+ return snapshot
+ },
+ }
+
+ let runtimeSnapshot = configManager.load()
if (config.debug.server || config.debug.viewer || config.debug.mcp) {
- useLogger().warn(
+ logger.warn(
[
'==============================================================================',
'SECURITY NOTICE:',
@@ -44,42 +69,122 @@ async function main() {
DebugService.getInstance().start()
}
- const { bot } = await initBot({
- botConfig: config.bot,
- plugins: [
- wrapPlugin(MineflayerArmorManager),
- wrapPlugin(MineflayerAutoEat),
- wrapPlugin(MineflayerCollectBlock),
- wrapPlugin(MineflayerPathfinder),
- wrapPlugin(MineflayerPVP),
- wrapPlugin(MineflayerTool),
- ],
- reconnect: {
- enabled: true,
- maxRetries: 5,
- },
- })
-
- if (config.debug.viewer) {
- setupMineflayerViewer(bot, { port: 3007, firstPerson: true })
- }
-
// Connect airi server
+ let airiConnectionLifecycle: ReturnType | null = null
const airiClient = new Client({
name: config.airi.clientName,
url: config.airi.wsBaseUrl,
+ possibleEvents: ['module:configure', 'module:announced', 'spark:command', 'context:update'],
+ autoConnect: false,
+ onError: error => airiConnectionLifecycle?.reportUnavailable(error),
+ onClose: () => airiConnectionLifecycle?.reportDisconnected(),
+ })
+ airiConnectionLifecycle = startAiriClientConnection(airiClient, {
+ logger,
+ url: config.airi.wsBaseUrl,
})
- // Load CognitiveEngine (LLM config is read from config internally)
- await bot.loadPlugin(CognitiveEngine({ airiClient }))
+ let activeRuntime: MinecraftBotRuntime | null = null
+ let viewerInitialized = false
- // Setup Tool Executor for Debug Dashboard
- const { setupToolExecutor } = await import('./debug/tool-executor')
- setupToolExecutor(bot)
+ async function createManagedBot(botConfig: typeof config.bot) {
+ const runtime = new MinecraftBotRuntime({
+ initialConfig: botConfig,
+ createBot: async (nextBotConfig) => {
+ config.bot = {
+ ...config.bot,
+ ...nextBotConfig,
+ }
+
+ const bot = await Mineflayer.asyncBuild({
+ botConfig: nextBotConfig,
+ plugins: [
+ wrapPlugin(MineflayerArmorManager),
+ wrapPlugin(MineflayerAutoEat),
+ wrapPlugin(MineflayerCollectBlock),
+ wrapPlugin(MineflayerPathfinder),
+ wrapPlugin(MineflayerPVP),
+ wrapPlugin(MineflayerTool),
+ ],
+ reconnect: {
+ enabled: true,
+ maxRetries: 5,
+ },
+ })
+
+ if (config.debug.viewer && !viewerInitialized) {
+ setupMineflayerViewer(bot, { port: 3007, firstPerson: true })
+ viewerInitialized = true
+ }
+
+ await bot.loadPlugin(CognitiveEngine({ airiClient }))
+
+ // Setup Tool Executor for Debug Dashboard
+ const { setupToolExecutor } = await import('./debug/tool-executor')
+ setupToolExecutor(bot)
+
+ return bot
+ },
+ })
+
+ await runtime.initialize()
+ activeRuntime = runtime
+
+ return runtime
+ }
+
+ async function ensureManagedBot() {
+ if (activeRuntime)
+ return
+
+ await createManagedBot(runtimeSnapshot.effectiveBotConfig)
+ }
+
+ async function applyConfiguredRuntime(nextConfig: unknown) {
+ runtimeSnapshot = configManager.save(nextConfig as Parameters[0])
+
+ if (!runtimeSnapshot.editableConfig.enabled) {
+ if (activeRuntime) {
+ await activeRuntime.stop()
+ activeRuntime = null
+ }
+ return
+ }
+
+ if (!activeRuntime) {
+ await ensureManagedBot()
+ return
+ }
+
+ await activeRuntime.updateBotConfig(runtimeSnapshot.effectiveBotConfig)
+ }
+
+ airiClient.onEvent('module:configure', async (event) => {
+ try {
+ await applyConfiguredRuntime(event.data.config)
+ }
+ catch {
+ // Keep failures local to the service process. Stage learns only from registry liveness
+ // and explicit bot-originated context pushes, not automated configure updates.
+ }
+ })
+
+ if (runtimeSnapshot.editableConfig.enabled) {
+ await ensureManagedBot()
+ }
process.on('SIGINT', () => {
- bot.stop()
- exit(0)
+ Promise.resolve(activeRuntime?.stop())
+ .catch((err: Error) => {
+ logger.errorWithError('Failed to stop Minecraft runtime cleanly', err)
+ })
+ .finally(() => {
+ // TODO: Add an explicit AIRI-side deregistration path on shutdown instead of relying on
+ // websocket close / heartbeat expiry. Right now the Minecraft page can briefly sit in a
+ // stale state after the bot exits, which is annoying and easy to misread as still online.
+ airiClient.close()
+ exit(0)
+ })
})
}
diff --git a/services/minecraft/src/minecraft-bot-runtime.ts b/services/minecraft/src/minecraft-bot-runtime.ts
new file mode 100644
index 000000000..32cf7a1a8
--- /dev/null
+++ b/services/minecraft/src/minecraft-bot-runtime.ts
@@ -0,0 +1,85 @@
+import type { Config } from './composables/config'
+
+import EventEmitter from 'eventemitter3'
+
+interface BotLifecycleEvents {
+ 'bot:connected': () => void
+ 'bot:disconnected': (reason?: string) => void
+ 'bot:error': (error: Error) => void
+}
+
+interface BotWithLifecycle {
+ bot: {
+ on: (event: 'spawn' | 'end' | 'error' | 'kicked', listener: (...args: any[]) => void) => void
+ off?: (event: 'spawn' | 'end' | 'error' | 'kicked', listener: (...args: any[]) => void) => void
+ }
+ stop: () => Promise
+}
+
+export class MinecraftBotRuntime extends EventEmitter {
+ private bot: BotWithLifecycle | null = null
+ private currentConfig: Config['bot']
+
+ private readonly onSpawn = () => {
+ this.emit('bot:connected')
+ }
+
+ private readonly onEnd = (reason?: string) => {
+ this.emit('bot:disconnected', reason)
+ }
+
+ private readonly onError = (error: Error) => {
+ this.emit('bot:error', error)
+ }
+
+ constructor(private readonly deps: {
+ createBot: (config: Config['bot']) => Promise
+ initialConfig: Config['bot']
+ }) {
+ super()
+ this.currentConfig = deps.initialConfig
+ }
+
+ async initialize() {
+ this.bot = await this.deps.createBot(this.currentConfig)
+ this.attachLifecycle(this.bot)
+ }
+
+ async updateBotConfig(config: Config['bot']) {
+ const previousBot = this.bot
+ if (previousBot) {
+ this.detachLifecycle(previousBot)
+ await previousBot.stop()
+ }
+
+ this.currentConfig = config
+ this.bot = await this.deps.createBot(this.currentConfig)
+ this.attachLifecycle(this.bot)
+ }
+
+ async stop() {
+ if (!this.bot)
+ return
+
+ const activeBot = this.bot
+ this.detachLifecycle(activeBot)
+ this.bot = null
+ await activeBot.stop()
+ }
+
+ private attachLifecycle(bot: BotWithLifecycle) {
+ const lifecycleSource = bot.bot
+ lifecycleSource.on('spawn', this.onSpawn)
+ lifecycleSource.on('end', this.onEnd)
+ lifecycleSource.on('kicked', this.onEnd)
+ lifecycleSource.on('error', this.onError)
+ }
+
+ private detachLifecycle(bot: BotWithLifecycle) {
+ const lifecycleSource = bot.bot
+ lifecycleSource.off?.('spawn', this.onSpawn)
+ lifecycleSource.off?.('end', this.onEnd)
+ lifecycleSource.off?.('kicked', this.onEnd)
+ lifecycleSource.off?.('error', this.onError)
+ }
+}