1// This tab, joined to its signed-in listener's own hub (worker/src/tab-hub.ts) 2// so the listener's own AI, connected through the hosted MCP 3// (worker/src/hosted-mcp.ts, /ai/), can play code here. 4// 5// The tab joins only while someone is signed in (account.mjs), over a 6// WebSocket to /jev/me/tabs that carries the session cookie; the Worker puts 7// it in that account's hub and no other. It leaves when they sign out. 8// 9// What the AI plays is not the page's code: it goes into the editor as 10// foreign code (`{ ai: app }`) and plays only in the sandbox (sandbox.mjs), 11// in an opaque origin, never in this page. So code an agent was talked into 12// writing cannot act as the listener here: it cannot read the page, post, 13// publish, or reach the session. Its Jev calls are charged to the 14// listener's own budget, made by this page (sandbox.mjs has why). Its logs 15// come back through the sandbox's protocol, rebuilt and bounded there. 16// 17// Commands, from the hub: play { code, app }, stop, get-code, get-logs, 18// status, and the tools both MCPs share (tabTools.mjs: the sounds, Jev's 19// picks, settings, the console, a song by id, a reaction). Each is answered 20// { type: 'reply', id, … }, bounded to what the hub takes (tab-hub-core.ts). 21import { account } from './account.mjs'; 22import { onSandboxLog, playForeign, sandbox, sandboxCycle, showForeign } from './sandbox.mjs'; 23import { playedForeign, TAB_TOOLS } from './tabTools.mjs'; 24 25export const TABS_PATH = '/jev/me/tabs'; 26// as the hub bounds them (worker/src/tab-hub-core.ts) 27export const MAX_CODE_BYTES = 64 * 1024; 28const MAX_LOG_LINES = 500; 29const MAX_LOG_CHARS = 500; 30const MAX_REPLY_BYTES = 250 * 1024; 31const PING_MS = 30_000; 32const RETRY_MS = [2_000, 5_000, 15_000, 60_000]; 33 34// What the sandbox has logged since the AI's last play. 35let logs = []; 36let logStart = typeof performance !== 'undefined' ? performance.now() : 0; 37export function recordLog(message, kind = '') { 38 const text = String(message).slice(0, MAX_LOG_CHARS); 39 const last = logs.at(-1); 40 if (last?.message === text && last.kind === kind) { 41 last.count++; 42 return; 43 } 44 logs.push({ at: (performance.now() - logStart) / 1000, message: text, kind: kind || '', count: 1 }); 45 if (logs.length > MAX_LOG_LINES) logs = logs.slice(-MAX_LOG_LINES); 46} 47export function clearLogs() { 48 logs = []; 49 logStart = performance.now(); 50} 51 52// What the editor holds, for get_status. 53export function codeKind(editor, foreign = sandbox.foreign()) { 54 if (foreign?.ai) return 'ai'; 55 if (foreign?.song) return 'listener'; 56 if (foreign?.link) return 'link'; 57 return editor?.code?.trim() ? 'own' : 'none'; 58} 59 60const bytes = (s) => new TextEncoder().encode(s).length; 61// the shared tools' page modules load with the first such command 62const tabTool = (editor, command) => import('./tabCommands.mjs').then((m) => m.runTabTool(editor, command)); 63const defaults = { showForeign, playForeign, sandbox, sandboxCycle, tabTool }; 64 65// Whether the sandbox's logs are this MCP's: code its AI played, or a 66// listener's song it played by id (tabTools.mjs). 67export const loggedForMcp = (foreign, played = playedForeign()) => Boolean(foreign?.ai || (foreign && foreign === played)); 68 69// One command from the hub, answered. `deps` are the sandbox's, for tests. 70export async function runCommand(editor, command, deps = defaults) { 71 if (!editor) return { error: 'the editor is not ready' }; 72 switch (command?.type) { 73 case 'play': { 74 const { code } = command; 75 if (typeof code !== 'string' || !code.trim()) return { error: 'code is empty' }; 76 if (bytes(code) > MAX_CODE_BYTES) return { error: `code is at most ${MAX_CODE_BYTES / 1024} KiB` }; 77 const app = typeof command.app === 'string' && command.app.trim() ? command.app.trim().slice(0, 80) : 'your AI'; 78 // whatever played before stops: the page's own song and any sandbox 79 await editor.stop(); 80 clearLogs(); 81 // never into the page: foreign, so it plays only in the sandbox 82 deps.showForeign(editor, code, { ai: app }); 83 return deps.playForeign(code); 84 } 85 case 'stop': 86 await editor.stop(); 87 return { ok: true }; 88 case 'get-code': 89 return { code: editor.code ?? '' }; 90 case 'get-logs': 91 return { logs }; 92 case 'status': { 93 const inSandbox = deps.sandbox.playing(); 94 const started = Boolean(editor.repl?.scheduler?.started); 95 const cycle = inSandbox ? deps.sandboxCycle() : started ? editor.repl.scheduler.now() : null; 96 return { 97 status: { playing: inSandbox || started, cycle: Number.isFinite(cycle) ? cycle : null, code: codeKind(editor, deps.sandbox.foreign()) }, 98 }; 99 } 100 default: { 101 if (!TAB_TOOLS.has(command?.type)) return { error: `unknown command ${command?.type}` }; 102 // a song played by id starts the logs afresh, as a play does 103 if (command.type === 'play-song' || (command.type === 'ask-song' && command.play)) clearLogs(); 104 return deps.tabTool(editor, command); 105 } 106 } 107} 108 109// The reply as sent: within what the hub accepts, whatever the tab had. 110export function replyFrame(id, reply) { 111 let frame = JSON.stringify({ type: 'reply', id, ...reply }); 112 if (frame.length <= MAX_REPLY_BYTES) return frame; 113 if (reply.logs) { 114 let kept = reply.logs; 115 while (kept.length && frame.length > MAX_REPLY_BYTES) { 116 kept = kept.slice(Math.ceil(kept.length / 2)); 117 frame = JSON.stringify({ type: 'reply', id, ...reply, logs: kept }); 118 } 119 if (frame.length <= MAX_REPLY_BYTES) return frame; 120 } 121 return JSON.stringify({ type: 'reply', id, error: 'the answer is too large to send' }); 122} 123 124// Joins while signed in; `onStatus` hears 'off' (signed out), 'connecting', 125// 'connected'. Returns a function that leaves. 126export function joinMyTabs(editorRef, sessionId, onStatus = () => {}) { 127 let socket = null; 128 let retry = null; 129 let ping = null; 130 let attempts = 0; 131 let userId = null; 132 let closed = false; 133 134 const unlog = onSandboxLog((message, kind) => { 135 if (loggedForMcp(sandbox.foreign())) recordLog(message, kind); 136 }); 137 138 const leave = () => { 139 clearTimeout(retry); 140 clearInterval(ping); 141 retry = ping = null; 142 const s = socket; 143 socket = null; 144 s?.close(1000, 'left'); 145 }; 146 147 const connect = () => { 148 if (closed || !userId || socket) return; 149 onStatus('connecting'); 150 const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 151 const s = new WebSocket(`${protocol}//${location.host}${TABS_PATH}?session_id=${encodeURIComponent(sessionId)}`); 152 socket = s; 153 s.onopen = () => { 154 attempts = 0; 155 onStatus('connected'); 156 ping = setInterval(() => s.readyState === WebSocket.OPEN && s.send('ping'), PING_MS); 157 }; 158 s.onmessage = async (event) => { 159 if (event.data === 'pong') return; 160 let message; 161 try { 162 message = JSON.parse(event.data); 163 } catch { 164 return; 165 } 166 if (message?.type !== 'command' || typeof message.id !== 'string') return; 167 let reply; 168 try { 169 reply = await runCommand(editorRef.current, message.command); 170 } catch (e) { 171 reply = { error: e?.message ?? String(e) }; 172 } 173 if (s.readyState === WebSocket.OPEN) s.send(replyFrame(message.id, reply)); 174 }; 175 s.onclose = (event) => { 176 clearInterval(ping); 177 ping = null; 178 if (socket !== s) return; 179 socket = null; 180 if (closed || !userId) return; 181 onStatus('connecting'); 182 // replaced by this tab's newer connection, or pushed out by newer tabs: stay out 183 if (event.code === 4008) { 184 onStatus('off'); 185 return; 186 } 187 retry = setTimeout(connect, RETRY_MS[Math.min(attempts++, RETRY_MS.length - 1)]); 188 }; 189 }; 190 191 const follow = () => { 192 const id = account.get().user?.id ?? null; 193 if (id === userId) return; 194 userId = id; 195 leave(); 196 attempts = 0; 197 if (userId) connect(); 198 else onStatus('off'); 199 }; 200 const unsubscribe = account.subscribe(follow); 201 follow(); 202 203 return () => { 204 closed = true; 205 unsubscribe(); 206 unlog(); 207 leave(); 208 }; 209}