/** * Mesh — BroadcastChannel-based cross-tab agent coordination. * Ported from agi-diy/docs/agent-mesh.js. * * Provides: * - Peer discovery via periodic ping * - Ring context (shared attention across tabs) * - invoke: send prompts to specific tabs * - broadcast: send to all tabs */ const CHANNEL_NAME = 'careless-mesh' const RING_KEY = 'careless-ring-context' const MAX_RING_ENTRIES = 50 export interface MeshPeer { id: string tabId: string label: string lastSeen: number url: string } export interface RingEntry { agentId: string agentType: string pageId: string text: string timestamp: number } type MsgType = 'ping' | 'pong' | 'invoke' | 'invoke-response' | 'ring-update' | 'ring-clear' | 'broadcast' export interface MeshMessage { type: MsgType payload: any source: MeshPeer timestamp: number } export class MeshImpl { private channel: BroadcastChannel | null = null private peers = new Map() private subscribers = new Map void>() private listeners = new Set<() => void>() private tabId: string private invokeHandler: ((prompt: string) => Promise) | null = null private pageLabel = 'careless' constructor() { this.tabId = Math.random().toString(36).slice(2, 10) } setPageLabel(label: string) { this.pageLabel = label } setInvokeHandler(handler: (prompt: string) => Promise) { this.invokeHandler = handler } init() { if (this.channel || typeof BroadcastChannel === 'undefined') return this.channel = new BroadcastChannel(CHANNEL_NAME) this.channel.onmessage = (ev) => this.handle(ev.data as MeshMessage) this.ping() setInterval(() => { this.ping() this.gc() }, 5000) } private me(): MeshPeer { return { id: this.pageLabel, tabId: this.tabId, label: this.pageLabel, lastSeen: Date.now(), url: location.href, } } private post(type: MsgType, payload: any = {}) { if (!this.channel) return const msg: MeshMessage = { type, payload, source: this.me(), timestamp: Date.now() } this.channel.postMessage(msg) } ping() { this.post('ping') } private handle(msg: MeshMessage) { const key = msg.source.tabId if (key === this.tabId) return this.peers.set(key, { ...msg.source, lastSeen: msg.timestamp }) if (msg.type === 'ping') this.post('pong') if (msg.type === 'invoke' && this.invokeHandler && msg.payload.target === this.tabId) { this.invokeHandler(msg.payload.prompt) .then(result => this.post('invoke-response', { requestId: msg.payload.requestId, result })) .catch(err => this.post('invoke-response', { requestId: msg.payload.requestId, error: String(err) })) } const subKey = `${msg.type}:${msg.payload?.requestId || ''}` const sub = this.subscribers.get(subKey) || this.subscribers.get(msg.type) sub?.(msg.payload, msg.source) this.listeners.forEach(l => l()) } private gc() { const cutoff = Date.now() - 15000 let changed = false for (const [k, p] of this.peers) { if (p.lastSeen < cutoff) { this.peers.delete(k); changed = true } } if (changed) this.listeners.forEach(l => l()) } getPeers(): MeshPeer[] { return Array.from(this.peers.values()) } subscribeChange(fn: () => void): () => void { this.listeners.add(fn) return () => this.listeners.delete(fn) } async invoke(tabId: string, prompt: string, timeoutMs = 60000): Promise { const requestId = Math.random().toString(36).slice(2, 10) return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.subscribers.delete(`invoke-response:${requestId}`) reject(new Error('invoke timeout')) }, timeoutMs) this.subscribers.set(`invoke-response:${requestId}`, (payload) => { clearTimeout(timer) this.subscribers.delete(`invoke-response:${requestId}`) if (payload.error) reject(new Error(payload.error)) else resolve(payload.result) }) this.post('invoke', { target: tabId, prompt, requestId }) }) } broadcast(message: string) { this.post('broadcast', { message }) } // Ring context (localStorage backed) getRing(): RingEntry[] { try { return JSON.parse(localStorage.getItem(RING_KEY) || '[]') } catch { return [] } } addToRing(agentId: string, agentType: string, text: string): RingEntry { const entry: RingEntry = { agentId, agentType, pageId: this.pageLabel, text: text.length > 500 ? text.slice(0, 500) + '…' : text, timestamp: Date.now(), } const ring = this.getRing() ring.push(entry) const trimmed = ring.slice(-MAX_RING_ENTRIES) localStorage.setItem(RING_KEY, JSON.stringify(trimmed)) this.post('ring-update', entry) this.listeners.forEach(l => l()) return entry } clearRing() { localStorage.setItem(RING_KEY, '[]') this.post('ring-clear', {}) this.listeners.forEach(l => l()) } getMyTabId() { return this.tabId } } export const mesh = new MeshImpl() // Listen for cross-tab storage events for ring if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key === RING_KEY) (mesh as any).listeners?.forEach?.((l: any) => l()) }) }