// module-agent-registry.js — VFS-backed agent directory with A2A contracts // Commands: agent-list, agent-register, agent-unregister, agent-status, contract-register, contract-list // A2A Scratchpad commands: a2a-post, a2a-poll, a2a-ack, a2a-reject, a2a-status, a2a-prune class AgentRegistryModule { constructor() { this.commands = {}; this.terminal = null; this._contracts = {}; this._a2aPruneTimer = null; } registerCommands() { this.commands['agent-list'] = () => this.listAgents(); this.commands['agent-status'] = (args) => this.showAgentStatus(args[0]); this.commands['agent-register'] = (args) => this.registerAgent(args); this.commands['agent-unregister'] = (args) => this.unregisterAgent(args[0]); this.commands['contract-register'] = (args) => this.registerContract(args); this.commands['contract-list'] = () => this.listContracts(); // A2A Scratchpad commands this.commands['a2a-post'] = (args) => this.a2aPostCommand(args); this.commands['a2a-poll'] = (args) => this.a2aPollCommand(args); this.commands['a2a-ack'] = (args) => this.a2aAckCommand(args); this.commands['a2a-reject'] = (args) => this.a2aRejectCommand(args); this.commands['a2a-status'] = () => this.a2aStatus(); this.commands['a2a-prune'] = () => this.a2aPrune(); } getHelpInfo() { return { 'a2a-post': 'a2a-post — Post an A2A message', 'a2a-poll': 'a2a-poll [agentId] — Poll for messages addressed to an agent (default: current or "user")', 'a2a-ack': 'a2a-ack — Acknowledge a message with result', 'a2a-reject': 'a2a-reject — Reject a message with error', 'a2a-status': 'a2a-status — Show scratchpad stats per agent', 'a2a-prune': 'a2a-prune — Clean up expired and old completed messages' }; } init(terminal) { this.terminal = terminal; if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdirRecursive('/agents'); if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdirRecursive('/.contracts'); this._loadContracts(); // Initialize scratchpad this._ensureScratchpad(); // Auto-prune every 60 seconds this._a2aPruneTimer = setInterval(() => this.a2aPrune(), 60000); // Save the spec-vs-reality delta table this._saveDeltaTable(); this.terminal.print('📋 Agent Registry loaded', 'info'); } // ─── Task 1: Save spec-vs-reality delta table ─── _saveDeltaTable() { try { const fs = this.terminal.fs; if (!fs.readDir('/_docs/')) fs.mkdirRecursive('/_docs'); const delta = `# Spec-vs-Reality Delta — 2026-06-17\n\n` + `| # | Whitepaper Concept | Spec'd Behavior | Current Reality | Δ | Priority |\n` + `|---|---|---|---|---|---|\n` + `| 1 | Hyper-Modular Kernel | Real-time self-rewriting drivers for task-specific optimization via WPM | WPM exists but is manual; no hot-patch daemon or runtime driver swap | Partial | Medium |\n` + `| 2 | A2A Protocol | Standardized neural communication layer for agent negotiation; typed messages, no central broker | VFS mailbox transport works (a2a-send/a2a-list/500ms polling) but orchestrator acts as central broker | Partial | High |\n` + `| 3 | Neural VFS | Semantic-based file storage (concept:// URIs) as primary access method; hierarchical paths are secondary | concept:// URIs work (nvfs-index/nvfs-resolve) but hierarchical paths remain primary access; concept resolution requires explicit index | Partial | High |\n` + `| 4 | Recursive Self-Optimization | Monitor → Profile → Diagnose → Patch → Test → Promote feedback loop | system-health telemetry captures latency/VFS stats but no auto-patch generation or hot-promotion pipeline | Partial | Medium |\n` + `| 5 | Agent Discovery | No central broker — agents discover each other via VFS registrations spontaneously | Orchestrator is central broker; all task delegation goes through it; agents don't autonomously discover each other | Critical Gap | Critical |\n` + `| 6 | Contract Schema | Every A2A message carries a contract schema defining capabilities and constraints | Contracts exist (contract-register/contract-list) but schema validation is basic substring matching; not enforced on every message | Partial | Medium |\n` + `| 7 | Semantic Addressing | Neural VFS as primary storage paradigm — users navigate by concept, not path | Hierarchical path access still primary; concept:// requires explicit nvfs-index; no transparent concept-to-path fallback | Critical Gap | Critical |\n` + `| 8 | Warm-start Guarantee | Kernel caches last N stable driver configs; boot in under 200ms | No golden config cache; full localStorage restore on every page load | Spec Violation | High |\n` + `| 9 | Self-rewrite Daemon | Monitors performance telemetry and generates optimized driver variants at runtime | No self-rewrite daemon exists; all module changes are manual | Missing | Low |\n` + `| 10 | Agent Lifecycle | spawn, sleep, wake, kill, restart policies with exponential backoff | Fully implemented in orchestrator with heartbeat, idle sweep, restart policies, VFS sleep serialization | Fully Aligned | — |\n\n` + `**Summary: 2 Critical gaps, 5 Partial, 1 Spec Violation, 1 Missing, 1 Fully Aligned**\n`; fs.writeFile('_docs/spec-vs-reality-delta.md', delta); this.terminal.print('📄 Spec-vs-Reality delta saved to /_docs/spec-vs-reality-delta.md', 'info'); } catch (e) { console.warn('Failed to save delta table:', e.message); } } // ─── A2A Scratchpad Helpers ─── _ensureScratchpad() { try { const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json'); if (!data) throw new Error('no scratchpad'); } catch (e) { this.terminal.fs.mkdirRecursive('/_agents'); this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', { messages: [], seq: 0, agents: {} }); } } _getScratchpad() { try { const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json'); if (data && data.messages) return data; } catch (e) {} return { messages: [], seq: 0, agents: {} }; } _saveScratchpad(sp) { try { this.terminal.fs.mkdirRecursive('/_agents'); this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', sp); } catch (e) { console.warn('Scratchpad save error:', e.message); } } // ─── A2A Scratchpad Functions ─── a2aPost(from, to, type, capability, payload, ttl) { if (!from || !to || !type) { this.terminal.print('a2aPost: from, to, and type are required', 'warning'); return null; } const sp = this._getScratchpad(); sp.seq = (sp.seq || 0) + 1; const msg = { id: `msg_${String(sp.seq).padStart(3, '0')}`, from: from, to: to, type: type, capability: capability || null, payload: payload || {}, ttl: (typeof ttl === 'number' ? ttl : 300000), status: 'pending', ts: Date.now(), reply_to: null }; sp.messages.push(msg); this._saveScratchpad(sp); return msg; } a2aPoll(agentId) { if (!agentId) return []; const sp = this._getScratchpad(); const pending = []; for (const msg of sp.messages) { if (msg.to === agentId && msg.status === 'pending') { msg.status = 'delivered'; pending.push(msg); } } if (pending.length > 0) this._saveScratchpad(sp); return pending; } a2aAck(messageId, resultPayload) { const sp = this._getScratchpad(); const msg = sp.messages.find(m => m.id === messageId); if (!msg) { this.terminal.print(`a2aAck: message ${messageId} not found`, 'warning'); return false; } if (msg.status === 'completed' || msg.status === 'failed') { this.terminal.print(`a2aAck: message ${messageId} already ${msg.status}`, 'warning'); return false; } msg.status = 'completed'; msg.result = resultPayload || {}; msg.completedAt = Date.now(); this._saveScratchpad(sp); return true; } a2aReject(messageId, error) { const sp = this._getScratchpad(); const msg = sp.messages.find(m => m.id === messageId); if (!msg) { this.terminal.print(`a2aReject: message ${messageId} not found`, 'warning'); return false; } if (msg.status === 'completed' || msg.status === 'failed') { this.terminal.print(`a2aReject: message ${messageId} already ${msg.status}`, 'warning'); return false; } msg.status = 'failed'; msg.error = error || 'unspecified error'; msg.completedAt = Date.now(); this._saveScratchpad(sp); return true; } a2aRegister(agentId, capabilities) { if (!agentId) return; const sp = this._getScratchpad(); sp.agents[agentId] = { last_poll: Date.now(), capabilities: capabilities || [] }; this._saveScratchpad(sp); } a2aUnregister(agentId) { if (!agentId) return; const sp = this._getScratchpad(); delete sp.agents[agentId]; this._saveScratchpad(sp); } a2aPrune() { try { const sp = this._getScratchpad(); const now = Date.now(); const before = sp.messages.length; sp.messages = sp.messages.filter(m => { // Remove expired messages (past TTL) if (m.status === 'pending' && (now - m.ts > m.ttl)) return false; // Remove completed/failed messages older than 1 hour if ((m.status === 'completed' || m.status === 'failed') && m.completedAt && (now - m.completedAt > 3600000)) return false; return true; }); const removed = before - sp.messages.length; if (removed > 0) this._saveScratchpad(sp); return removed; } catch (e) { console.warn('a2aPrune error:', e.message); return 0; } } a2aStatus() { const sp = this._getScratchpad(); const counts = {}; for (const msg of sp.messages) { if (!counts[msg.to]) counts[msg.to] = { pending: 0, delivered: 0, completed: 0, failed: 0 }; if (msg.status === 'pending') counts[msg.to].pending++; else if (msg.status === 'delivered') counts[msg.to].delivered++; else if (msg.status === 'completed') counts[msg.to].completed++; else if (msg.status === 'failed') counts[msg.to].failed++; } this.terminal.print('📊 A2A Scratchpad Status:', 'header'); this.terminal.print(` Total messages: ${sp.messages.length}`, 'info'); this.terminal.print(` Registered agents: ${Object.keys(sp.agents).length}`, 'info'); this.terminal.print(` Sequence: ${sp.seq}`, 'info'); const agentNames = Object.keys(counts); if (agentNames.length === 0) { this.terminal.print(' No messages in scratchpad', 'info'); return; } for (const agentId of agentNames) { const c = counts[agentId]; const total = c.pending + c.delivered + c.completed + c.failed; const caps = sp.agents[agentId] ? ` [${sp.agents[agentId].capabilities.join(', ')}]` : ''; this.terminal.print(` ${agentId}${caps}: ${total} msgs (⏳${c.pending} 📬${c.delivered} ✅${c.completed} ❌${c.failed})`, 'info'); } } _a2aCleanup() { // Remove agent entries from scratchpad when agent directory is deleted from /agents/ try { const sp = this._getScratchpad(); const agentDirs = {}; const dir = this.terminal.fs.readDir('/agents/'); if (dir) { for (const entry of dir) { if (entry.type === 'dir') agentDirs[entry.name] = true; } } let changed = false; for (const agentId of Object.keys(sp.agents)) { if (!agentDirs[agentId]) { delete sp.agents[agentId]; changed = true; } } if (changed) this._saveScratchpad(sp); } catch (e) { console.warn('a2aCleanup error:', e.message); } } // ─── A2A Command Handlers ─── a2aPostCommand(args) { // a2a-post if (args.length < 3) { return this.terminal.print('Usage: a2a-post ', 'warning'); } const to = args[0]; const type = args[1]; const capability = args[2]; const payloadRaw = args.slice(3).join(' '); let payload = {}; try { // Try parsing as JSON payload = JSON.parse(payloadRaw || '{}'); } catch (e) { // Treat as plain text payload = { text: payloadRaw }; } const from = 'user'; const ttl = 300000; const msg = this.a2aPost(from, to, type, capability, payload, ttl); if (msg) { this.terminal.print(`📨 A2A message ${msg.id} posted to ${to} (type: ${type})`, 'success'); } } a2aPollCommand(args) { // Use explicit agent ID if provided, otherwise try to determine current agent let agentId = (args && args[0]) || 'user'; if (!args || !args[0]) { try { const orchestrator = this.terminal.moduleManager.modules['OrchestratorModule']; if (orchestrator && orchestrator._agents) { const myAgents = Object.entries(orchestrator._agents).filter(([, a]) => a.status === 'busy'); if (myAgents.length > 0) agentId = myAgents[0][0]; } } catch (e) {} } const msgs = this.a2aPoll(agentId); if (msgs.length === 0) { this.terminal.print(`📭 No pending messages for ${agentId}`, 'info'); return; } this.terminal.print(`📬 ${msgs.length} message(s) for ${agentId}:`, 'header'); for (const msg of msgs) { const capInfo = msg.capability ? ` [${msg.capability}]` : ''; this.terminal.print(` ${msg.id} from ${msg.from} (${msg.type}${capInfo})`, 'info'); this.terminal.print(` Payload: ${JSON.stringify(msg.payload).substring(0, 120)}`, 'text-muted'); } } a2aAckCommand(args) { if (args.length < 1) { return this.terminal.print('Usage: a2a-ack [result...]', 'warning'); } const msgId = args[0]; const resultRaw = args.slice(1).join(' '); let result = {}; try { result = JSON.parse(resultRaw || '{}'); } catch (e) { result = { text: resultRaw }; } if (this.a2aAck(msgId, result)) { this.terminal.print(`✅ A2A message ${msgId} completed`, 'success'); } } a2aRejectCommand(args) { if (args.length < 1) { return this.terminal.print('Usage: a2a-reject [error...]', 'warning'); } const msgId = args[0]; const error = args.slice(1).join(' ') || 'rejected by user'; if (this.a2aReject(msgId, error)) { this.terminal.print(`❌ A2A message ${msgId} rejected: ${error}`, 'warning'); } } _loadContracts() { try { const data = this.terminal.fs.readFile('/.contracts/index.json'); if (data) this._contracts = JSON.parse(data); } catch (e) {} // Try loading individual contract files const dir = this.terminal.fs.readDir('/.contracts/'); if (dir) { dir.forEach(e => { if (e.type === 'file' && e.name.endsWith('.contract.json')) { try { const c = JSON.parse(this.terminal.fs.readFile('/.contracts/' + e.name)); this._contracts[c.role] = c; } catch (e) { console.warn('Bad contract:', e.name, e.message); } } }); } } _saveContracts() { try { this.terminal.fs.fsWriteJSON('/.contracts/index.json', this._contracts); } catch (e) { console.error('Contract save error:', e); } } registerAgent(rawArgs) { // MAINT-008: callers pass the command word first (['register', id, cap, cap…]), and the // old code took args[0] as the id and only args[1] as the capability list — so the boot // log read `Agent 'register' registered with 1 capabilities`. Strip the command word and // accept both `cap1,cap2` and `cap1 cap2 cap3`. const args = (rawArgs && rawArgs[0] === 'register') ? rawArgs.slice(1) : (rawArgs || []); const id = args[0] || `agent-${Date.now()}`; const capsArg = args.slice(1).join(','); const capabilities = capsArg ? capsArg.split(',').map(s => s.trim()).filter(Boolean) : ['list_files', 'read_file']; // MAINT-007/009: relative mkdir/rm resolved against the user's cwd, so a registration made // from /docs created /docs/agents/ and an unregister from anywhere else removed // nothing. Every path here is absolute. const agentDir = `/agents/${id}`; if (!this.terminal.fs.readDir(agentDir + '/')) { this.terminal.fs.mkdirRecursive(agentDir); } this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({ id, status: 'idle', model: null, capabilities, registered: new Date().toISOString(), lastHeartbeat: new Date().toISOString(), contract: null })); // Auto-register in A2A scratchpad this.a2aRegister(id, capabilities); this.terminal.print(`✅ Agent '${id}' registered with ${capabilities.length} capabilities`, 'success'); } registerContract(args) { const role = args[0]; if (!role) return this.terminal.print('Usage: contract-register [input_fields...] [--output output_fields...]', 'warning'); const inputIdx = args.indexOf('--output'); const hasOutputFlag = inputIdx !== -1; let inputFields = [], outputFields = []; if (hasOutputFlag) { inputFields = args.slice(1, inputIdx).filter(f => !f.startsWith('--')); outputFields = args.slice(inputIdx + 1).filter(f => !f.startsWith('--')); } else { inputFields = args.slice(1).filter(f => !f.startsWith('--')); outputFields = ['result']; // default output } const contract = { role, capabilities: inputFields, input_schema: inputFields, output_schema: outputFields, created: new Date().toISOString(), updated: new Date().toISOString() }; this._contracts[role] = contract; // Save as individual contract file this.terminal.fs.fsWriteJSON(`/.contracts/${role}.contract.json`, contract); this._saveContracts(); this.terminal.print(`✅ Contract registered for role '${role}'`, 'success'); this.terminal.print(` Input fields: ${inputFields.join(', ') || '(free-form)'}`, 'info'); this.terminal.print(` Output fields: ${outputFields.join(', ') || '(free-form)'}`, 'info'); } listContracts() { const roles = Object.keys(this._contracts); if (roles.length === 0) return this.terminal.print('No contracts registered', 'info'); this.terminal.print(`📜 A2A Contracts (${roles.length}):`, 'header'); roles.forEach(r => { const c = this._contracts[r]; this.terminal.print(` ${r} — input:[${c.input_schema.join(',')}] → output:[${c.output_schema.join(',')}]`, 'info'); }); } validateTask(role, task) { const contract = this._contracts[role]; if (!contract) return { valid: true, reason: 'no contract for role' }; // permissive fallback if (contract.input_schema.length === 0) return { valid: true, reason: 'free-form input' }; // Simple validation: check required input fields are present in task const missing = contract.input_schema.filter(f => !task.toLowerCase().includes(f.toLowerCase())); if (missing.length > 0) { return { valid: false, reason: `missing required fields: ${missing.join(', ')}` }; } return { valid: true, reason: 'matched' }; } getContract(role) { return this._contracts[role] || null; } listAgents() { const agents = this.terminal.fs.ls('/agents/'); if (!agents || agents.length === 0) { return this.terminal.print('No agents registered', 'info'); } // MAINT-009: a directory alone is not a registration — anything without a readable // status.json is a leftover directory, not an agent. Listing them as agents made a // hand-made /agents/foo look like a live agent with unknown status. const registered = [], orphaned = []; agents.forEach(a => { if (a.type !== 'dir' || a.name === 'configured') return; let s = null; try { const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); if (data) s = JSON.parse(data); } catch (e) { s = null; } if (s && s.id) registered.push({ name: a.name, s }); else orphaned.push(a.name); }); if (registered.length) { this.terminal.print(`📋 Registered Agents (${registered.length}):`, 'header'); registered.forEach(({ name, s }) => { this.terminal.print(` ${name} — ${s.status} (${(s.capabilities || []).length} capabilities)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info'); }); } else { this.terminal.print('No agents registered', 'info'); } if (orphaned.length) { this.terminal.print(`⚠️ ${orphaned.length} directory(ies) in /agents have no status.json (not registered): ${orphaned.join(', ')}`, 'warning'); } } unregisterAgent(id) { // Unregister from A2A scratchpad this.a2aUnregister(id); // MAINT-007: absolute path — a relative rm resolved against the cwd and left the // directory behind whenever the user was not sitting in /. const ok = this.terminal.fs.rm('/agents/' + id); if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success'); else this.terminal.print(`❌ Agent '${id}' not found`, 'error'); } showAgentStatus(id) { if (!id) return this.terminal.print('Usage: agent-status ', 'error'); const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); if (!data) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); const s = JSON.parse(data); this.terminal.print(`Agent: ${s.id}`, 'header'); this.terminal.print(` Status: ${s.status}`, 'info'); this.terminal.print(` Capabilities: ${(s.capabilities || []).join(', ')}`, 'info'); this.terminal.print(` Contract: ${s.contract || 'none'}`, 'info'); this.terminal.print(` Registered: ${s.registered}`, 'info'); this.terminal.print(` Last Heartbeat: ${s.lastHeartbeat}`, 'info'); } getAgent(id) { const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); return data ? JSON.parse(data) : null; } updateHeartbeat(id) { const agent = this.getAgent(id); if (!agent) return false; agent.lastHeartbeat = new Date().toISOString(); this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); return true; } }