The rules of a signed-in user's tab hub (tab-hub.ts), as plain code so they run in vitest: what a tab may send, how big, how many tabs, and what the hosted MCP (hosted-mcp.ts) may ask a tab to do.
A tab is one open REPL page of one signed-in user, joined to that user's
TabHub (TAB_HUB.idFromName(userId)) over a WebSocket. Everything a tab
sends is untrusted in shape and size (it is the user's own page, but the
page relays what a sandboxed song logged), so every reply is rebuilt here
from typed, bounded fields before the MCP reads it.
10import { MAX_THEME_CHARS, settingsView } from './mcp-settings.mjs';
A tab's id: the REPL's session id (useWebSocketMCP.jsx), as the dev hub takes it.
13export const SESSION = /^[a-z0-9-]{1,32}$/;
Tabs one user may have joined at once; the oldest goes when another joins.
15export const MAX_TABS = 8;
The largest frame a tab may send (a reply with its logs); larger closes it.
17export const MAX_FRAME_BYTES = 256 * 1024;
How long a tab has to answer a command. A play waits for the sandbox to start and the song's first samples, so it gets longer.
Asking Jev from the tab (ask_jev_sound's banks, ask_jev_song) is a few relay calls in a row, each with its retries.
Lines of log a reply may carry, and characters in each.
The largest code the hosted MCP plays: a listener song's (content.ts MAX_CODE_BYTES).
34export const MAX_PLAY_CODE_BYTES = 64 * 1024;
Sounds a list_sounds reply carries at most, and a Jev pick's alternatives.
The tools both MCPs share (shared-mcp.ts; website/src/jev/tabTools.mjs).
42export type SharedCommand = 43 | { type: 'list-sounds'; category?: string; filter?: string; offset: number; limit: number } 44 | { type: 'ask-sound'; description: string; category?: string; filter?: string } 45 | { type: 'get-settings' } 46 | { type: 'set-settings'; changes: Record<string, string | number | boolean> } 47 | { type: 'get-console' } 48 | { type: 'play-song'; id: string } 49 | { type: 'ask-song'; mood: string; play: boolean } 50 | { type: 'react'; kind: 'fire' | 'sleep' };
52export type TabCommand = 53 | { type: 'play'; code: string; app: string } 54 | { type: 'stop' } 55 | { type: 'get-code' } 56 | { type: 'get-logs' } 57 | { type: 'status' } 58 | SharedCommand; 59 60export type LogLine = { at: number; message: string; kind: string; count: number }; 61export type TabStatus = { playing: boolean; cycle: number | null; code: 'none' | 'own' | 'ai' | 'listener' | 'link' }; 62export type Sound = { name: string; category: string; type: string; variants: number; use: string }; 63export type SoundPick = { 64 picks: (Sound & { p: number | null })[]; 65 confidence: number | null; 66 candidates: number; 67 banks: number; 68 calls: number; 69 scope: 'all' | 'finalists'; 70}; 71export type SongPlayed = { id: string; title: string; where: 'page' | 'sandbox' | null; p: number | null; confidence: number | null }; 72export type Reaction = { section: string; kind: 'fire' | 'sleep'; counted: boolean }; 73export type TabReply = { 74 ok?: boolean; 75 error?: string; 76 code?: string; 77 logs?: LogLine[]; 78 status?: TabStatus; 79 sounds?: { total: number; items: Sound[] }; 80 pick?: SoundPick; 81 settings?: Record<string, string | number | boolean>; 82 themes?: string[]; 83 song?: SongPlayed; 84 reaction?: Reaction; 85}; 86 87const isPlain = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x); 88const str = (x: unknown, max: number) => (typeof x === 'string' ? x.slice(0, max) : undefined); 89const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined); 90const CODE_KINDS = new Set<TabStatus['code']>(['none', 'own', 'ai', 'listener', 'link']); 91const prob = (x: unknown) => { 92 const n = num(x); 93 return n !== undefined && n >= 0 && n <= 1 ? n : null; 94}; 95const count = (x: unknown, max = 1e6) => Math.max(0, Math.min(max, Math.floor(num(x) ?? 0))); 96 97function sound(s: Record<string, unknown>): Sound { 98 return { 99 name: str(s.name, 128) ?? '', 100 category: str(s.category, 32) ?? '', 101 type: str(s.type, 32) ?? '', 102 variants: count(s.variants), 103 use: str(s.use, 300) ?? '', 104 }; 105}
A tab's message: a reply to command id, rebuilt; null when it is not one.
108export function parseReply(raw: string | ArrayBuffer): { id: string; reply: TabReply } | null { 109 if (typeof raw !== 'string' || raw.length > MAX_FRAME_BYTES) return null; 110 let m: unknown; 111 try { 112 m = JSON.parse(raw); 113 } catch { 114 return null; 115 } 116 if (!isPlain(m) || m.type !== 'reply' || typeof m.id !== 'string' || m.id.length > 64) return null; 117 return { id: m.id, reply: rebuildReply(m) }; 118}
A reply's fields, typed and bounded, whatever the tab sent: the hosted hub's parseReply, and the dev hub's replies before a shared tool reads them (shared-mcp.ts).
123export function rebuildReply(m: Record<string, unknown>): TabReply { 124 const reply: TabReply = {}; 125 if (m.ok === true) reply.ok = true; 126 const error = str(m.error, 2000); 127 if (error !== undefined) reply.error = error; 128 const code = str(m.code, MAX_FRAME_BYTES); 129 if (code !== undefined) reply.code = code; 130 if (Array.isArray(m.logs)) { 131 reply.logs = m.logs 132 .slice(-MAX_LOG_LINES) 133 .filter(isPlain) 134 .map((l) => ({ 135 at: num(l.at) ?? 0, 136 message: str(l.message, MAX_LOG_CHARS) ?? '', 137 kind: str(l.kind, 16) ?? '', 138 count: Math.max(1, Math.min(1e6, Math.floor(num(l.count) ?? 1))), 139 })); 140 } 141 if (isPlain(m.status)) { 142 const s = m.status; 143 const kind = CODE_KINDS.has(s.code as TabStatus['code']) ? (s.code as TabStatus['code']) : 'none'; 144 reply.status = { playing: s.playing === true, cycle: num(s.cycle) ?? null, code: kind }; 145 } 146 if (isPlain(m.sounds) && Array.isArray(m.sounds.items)) { 147 reply.sounds = { total: count(m.sounds.total), items: m.sounds.items.slice(0, MAX_SOUNDS).filter(isPlain).map(sound) }; 148 } 149 if (isPlain(m.pick) && Array.isArray(m.pick.picks)) { 150 const p = m.pick; 151 reply.pick = { 152 picks: (p.picks as unknown[]).slice(0, MAX_PICKS).filter(isPlain).map((x) => ({ ...sound(x), p: prob(x.p) })), 153 confidence: prob(p.confidence), 154 candidates: count(p.candidates), 155 banks: count(p.banks, 64), 156 calls: count(p.calls, 64), 157 scope: p.scope === 'finalists' ? 'finalists' : 'all', 158 }; 159 } 160 if (isPlain(m.settings)) reply.settings = settingsView(m.settings) as Record<string, string | number | boolean>; 161 if (Array.isArray(m.themes)) { 162 reply.themes = m.themes.slice(0, MAX_THEMES).filter((t): t is string => typeof t === 'string' && t.length <= MAX_THEME_CHARS); 163 } 164 if (isPlain(m.song)) { 165 const s = m.song; 166 reply.song = { 167 id: str(s.id, 96) ?? '', 168 title: str(s.title, 120) ?? '', 169 where: s.where === 'page' || s.where === 'sandbox' ? s.where : null, 170 p: prob(s.p), 171 confidence: prob(s.confidence), 172 }; 173 } 174 if (isPlain(m.reaction)) { 175 const r = m.reaction; 176 reply.reaction = { section: str(r.section, 64) ?? '', kind: r.kind === 'sleep' ? 'sleep' : 'fire', counted: r.counted === true }; 177 } 178 return reply; 179}
Which tab a command goes to: the one named, or the only one open.
182export function pickTab(open: string[], sessionId: unknown): string { 183 if (sessionId !== undefined) { 184 if (typeof sessionId !== 'string' || !SESSION.test(sessionId)) throw new Error('session_id is 1-32 of a-z, 0-9 and -'); 185 if (!open.includes(sessionId)) { 186 throw new Error(`you have no open tab ${sessionId}; open: ${open.join(', ') || 'none'}`); 187 } 188 return sessionId; 189 } 190 if (open.length === 1) return open[0]; 191 if (!open.length) { 192 throw new Error('you have no jevstrudel tab open: open the site, signed in on this account, and click the page once'); 193 } 194 throw new Error(`you have several tabs open (${open.join(', ')}); pass session_id`); 195}