jevstrudel.git / website / src / jev / myTabs.mjs
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}