jevstrudel.git / website / src / jev / myTabs.mjs

This tab, joined to its signed-in listener's own hub (worker/src/tab-hub.ts) so the listener's own AI, connected through the hosted MCP (worker/src/hosted-mcp.ts, /ai/), can play code here.

The tab joins only while someone is signed in (account.mjs), over a WebSocket to /jev/me/tabs that carries the session cookie; the Worker puts it in that account's hub and no other. It leaves when they sign out.

What the AI plays is not the page's code: it goes into the editor as foreign code ({ ai: app }) and plays only in the sandbox (sandbox.mjs), in an opaque origin, never in this page. So code an agent was talked into writing cannot act as the listener here: it cannot read the page, post, publish, or reach the session. Its Jev calls are charged to the listener's own budget, made by this page (sandbox.mjs has why). Its logs come back through the sandbox's protocol, rebuilt and bounded there.

Commands, from the hub: play { code, app }, stop, get-code, get-logs, status, and the tools both MCPs share (tabTools.mjs: the sounds, Jev's picks, settings, the console, a song by id, a reaction). Each is answered { 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';
25export const TABS_PATH = '/jev/me/tabs';

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];

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}

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}
60const bytes = (s) => new TextEncoder().encode(s).length;

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 };

Whether the sandbox's logs are this MCP's: code its AI played, or a listener's song it played by id (tabTools.mjs).

67export const loggedForMcp = (foreign, played = playedForeign()) => Boolean(foreign?.ai || (foreign && foreign === played));

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}

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}

Joins while signed in; onStatus hears 'off' (signed out), 'connecting', '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}