gabriel / musehub public
blob.ts typescript
531 lines 22.6 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 157 days ago
1 /**
2 * blob.ts — File blob viewer with syntax highlighting.
3 *
4 * Two rendering paths:
5 * 1. SSR path: server renders line-numbered table; we post-process with hljs.
6 * 2. Client path: we fetch via API and render with hljs ourselves.
7 *
8 * highlight.js is imported à la carte (same set as diff.ts) to keep the
9 * bundle lean — no auto-detection, language resolved from file extension.
10 */
11
12 import hljs from 'highlight.js/lib/core';
13 import { extToLang } from '../lang-detect.ts';
14
15 import python from 'highlight.js/lib/languages/python';
16 import typescript from 'highlight.js/lib/languages/typescript';
17 import javascript from 'highlight.js/lib/languages/javascript';
18 import rust from 'highlight.js/lib/languages/rust';
19 import go from 'highlight.js/lib/languages/go';
20 import swift from 'highlight.js/lib/languages/swift';
21 import kotlin from 'highlight.js/lib/languages/kotlin';
22 import java from 'highlight.js/lib/languages/java';
23 import ruby from 'highlight.js/lib/languages/ruby';
24 import cpp from 'highlight.js/lib/languages/cpp';
25 import haskell from 'highlight.js/lib/languages/haskell';
26 import json from 'highlight.js/lib/languages/json';
27 import yaml from 'highlight.js/lib/languages/yaml';
28 import toml from 'highlight.js/lib/languages/ini';
29 import bash from 'highlight.js/lib/languages/bash';
30 import xml from 'highlight.js/lib/languages/xml';
31 import css from 'highlight.js/lib/languages/css';
32 import sql from 'highlight.js/lib/languages/sql';
33 import markdown from 'highlight.js/lib/languages/markdown';
34 import plaintext from 'highlight.js/lib/languages/plaintext';
35
36 hljs.registerLanguage('python', python);
37 hljs.registerLanguage('typescript', typescript);
38 hljs.registerLanguage('javascript', javascript);
39 hljs.registerLanguage('rust', rust);
40 hljs.registerLanguage('go', go);
41 hljs.registerLanguage('swift', swift);
42 hljs.registerLanguage('kotlin', kotlin);
43 hljs.registerLanguage('java', java);
44 hljs.registerLanguage('ruby', ruby);
45 hljs.registerLanguage('cpp', cpp);
46 hljs.registerLanguage('haskell', haskell);
47 hljs.registerLanguage('json', json);
48 hljs.registerLanguage('yaml', yaml);
49 hljs.registerLanguage('toml', toml);
50 hljs.registerLanguage('bash', bash);
51 hljs.registerLanguage('xml', xml);
52 hljs.registerLanguage('css', css);
53 hljs.registerLanguage('sql', sql);
54 hljs.registerLanguage('markdown', markdown);
55 hljs.registerLanguage('plaintext', plaintext);
56
57 // ── Types ─────────────────────────────────────────────────────────────────────
58
59 // Map of symbol display name → [startLine, endLine], populated from page_json.
60 // Used to resolve #S:SymbolName hash fragments to #Lstart-Lend range anchors.
61 type SymbolLineMap = Record<string, [number, number]>;
62
63 interface BlobCfg {
64 repoId: string;
65 ref: string;
66 filePath: string;
67 filename: string;
68 owner: string;
69 repoSlug: string;
70 base: string;
71 ssrBlobRendered: boolean;
72 hasOutline: boolean;
73 symbolLines: SymbolLineMap;
74 }
75
76 interface BlobData {
77 rawUrl?: string;
78 fileType?: string;
79 filename?: string;
80 sizeBytes?: number;
81 sha?: string;
82 createdAt?: string;
83 contentText?: string;
84 }
85
86 declare const escHtml: (s: unknown) => string;
87
88 // ── Core highlighter ──────────────────────────────────────────────────────────
89
90 /**
91 * Highlight `code` with hljs and return an array of HTML strings, one per
92 * line. We highlight the whole file at once so multi-line tokens (strings,
93 * comments, template literals) are coloured correctly, then split on newlines.
94 */
95 function highlightLines(code: string, lang: string): string[] {
96 let highlighted: string;
97 try {
98 highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
99 } catch {
100 highlighted = escHtml(code);
101 }
102 const lines = highlighted.split('\n');
103 // hljs adds a trailing \n which produces a spurious empty last element
104 if (lines.length && lines[lines.length - 1] === '') lines.pop();
105 return lines;
106 }
107
108 // ── SSR post-process — apply hljs to the server-rendered table ────────────────
109
110 /**
111 * The SSR blob template renders each line as a plain `<td class="blob-code">`.
112 * We re-collect all plain-text lines, highlight the full source, then inject
113 * the coloured HTML back cell by cell. This preserves the SSR line-number
114 * anchors (`id="L1"` etc.) while adding colour.
115 */
116 function applySsrHighlighting(filename: string): void {
117 // Support both old class names (blob-*) and new blob2-* names
118 const cells = Array.from(
119 document.querySelectorAll<HTMLTableCellElement>(
120 '.blob2-line-table td.blob2-code, .blob-line-table td.blob-code',
121 ),
122 );
123 if (cells.length === 0) return;
124
125 const lang = extToLang(filename);
126 if (lang === 'plaintext') return; // nothing useful to highlight
127
128 // Re-collect raw text: textContent strips any existing spans safely
129 const rawLines = cells.map(td => td.textContent ?? '');
130 const fullSrc = rawLines.join('\n');
131
132 const coloredLines = highlightLines(fullSrc, lang);
133
134 cells.forEach((td, i) => {
135 // Use innerHTML — the hljs output is safe HTML with <span> tokens only
136 td.innerHTML = coloredLines[i] ?? td.innerHTML;
137 td.classList.add('hljs');
138 });
139
140 // Mark the table so CSS can apply the hljs theme background
141 (document.querySelector('.blob2-line-table') ?? document.querySelector('.blob-line-table'))
142 ?.classList.add('hljs');
143 }
144
145 // ── Utility helpers ───────────────────────────────────────────────────────────
146
147 function fmtSize(bytes: number | null | undefined): string {
148 if (bytes == null) return '';
149 if (bytes < 1024) return bytes + '\u00a0B';
150 if (bytes < 1048576) return (bytes / 1024).toFixed(1) + '\u00a0KB';
151 return (bytes / 1048576).toFixed(1) + '\u00a0MB';
152 }
153
154 function fmtDate(iso: string | undefined): string {
155 if (!iso) return '';
156 try {
157 return new Date(iso).toLocaleDateString(undefined, {
158 year: 'numeric', month: 'short', day: 'numeric',
159 });
160 } catch { return iso; }
161 }
162
163 function shortSha(sha: string): string {
164 const idx = sha.indexOf(':');
165 const hex = idx >= 0 ? sha.slice(idx + 1) : sha;
166 return hex.slice(0, 12);
167 }
168
169 // ── Hex dump ──────────────────────────────────────────────────────────────────
170
171 function renderHexDump(arrayBuffer: ArrayBuffer): string {
172 const bytes = new Uint8Array(arrayBuffer);
173 const limit = Math.min(bytes.length, 512);
174 let out = '';
175 for (let i = 0; i < limit; i += 16) {
176 const chunk = bytes.slice(i, i + 16);
177 const offset = i.toString(16).padStart(8, '0');
178 const hexPart = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ').padEnd(47, ' ');
179 const asciiPart = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join('');
180 out += '<span class="hex-offset">' + offset + '</span>'
181 + '<span class="hex-bytes">' + escHtml(hexPart) + '</span>'
182 + '<span class="hex-ascii">' + escHtml(asciiPart) + '</span>\n';
183 }
184 return out;
185 }
186
187 // ── Action buttons ────────────────────────────────────────────────────────────
188
189 function buildActions(cfg: BlobCfg, data: BlobData, rawUrl: string): string {
190 let actions = '<a class="btn-blob btn-blob-secondary" href="' + escHtml(rawUrl) + '" download>'
191 + '⬇&#65039;&nbsp;Raw</a>';
192 if (data.fileType === 'midi') {
193 const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
194 actions += '&nbsp;<a class="btn-blob btn-blob-primary" href="' + escHtml(rollUrl) + '">'
195 + '🎹&nbsp;View in Piano Roll</a>';
196 } else if (data.fileType === 'audio') {
197 const listenUrl = cfg.base + '/listen/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
198 actions += '&nbsp;<a class="btn-blob btn-blob-primary" href="' + escHtml(listenUrl) + '">'
199 + '🎵&nbsp;Listen</a>';
200 }
201 return actions;
202 }
203
204 // ── Render highlighted code table (client-side path) ─────────────────────────
205
206 function buildCodeTable(code: string, lang: string): string {
207 const lines = highlightLines(code, lang);
208 const rows = lines.map((html, i) =>
209 `<tr id="L${i + 1}" class="blob2-line">`
210 + `<td class="blob2-ln"><a href="#L${i + 1}" class="blob2-ln-link" data-line="${i + 1}">${i + 1}</a></td>`
211 + `<td class="blob2-code hljs">${html}</td>`
212 + `</tr>`,
213 ).join('');
214 return `<div class="blob-viewer"><table class="blob2-line-table hljs"><tbody>${rows}</tbody></table></div>`;
215 }
216
217 // ── Body renderer (client-side fetch path) ────────────────────────────────────
218
219 function renderBlobBody(cfg: BlobCfg, data: BlobData, rawUrl: string): string | null {
220 const rollUrl = cfg.base + '/piano-roll/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
221
222 switch (data.fileType) {
223 case 'midi':
224 return '<div class="blob-midi-banner">'
225 + '<span class="blob-midi-icon">🎹</span>'
226 + '<div class="blob-midi-title">' + escHtml(data.filename ?? cfg.filename) + '</div>'
227 + '<div class="blob-midi-sub">MIDI file</div>'
228 + '<a class="btn-blob btn-blob-primary" href="' + escHtml(rollUrl) + '">🎹&nbsp;View in Piano Roll</a>'
229 + '</div>';
230 case 'audio':
231 return '<div class="blob-audio-wrap">'
232 + '<span class="blob-audio-icon">🎵</span>'
233 + '<div class="blob-audio-name">' + escHtml(data.filename ?? cfg.filename) + '</div>'
234 + '<audio class="blob-audio-player" controls preload="metadata" src="' + escHtml(rawUrl) + '">'
235 + 'Your browser does not support audio. <a href="' + escHtml(rawUrl) + '">Download</a></audio></div>';
236 case 'image':
237 return '<div class="blob-img-wrap">'
238 + '<img class="blob-img" src="' + escHtml(rawUrl) + '" alt="' + escHtml(data.filename ?? cfg.filename) + '">'
239 + '</div>';
240 default:
241 if (data.contentText != null) {
242 const lang = extToLang(cfg.filename);
243 return buildCodeTable(data.contentText, lang);
244 }
245 return null; // binary → async hex dump
246 }
247 }
248
249 // ── Hex preview for binary/unknown files ──────────────────────────────────────
250
251 async function fetchHexPreview(rawUrl: string, bodyEl: HTMLElement, sizeBytes: number): Promise<void> {
252 try {
253 const resp = await fetch(rawUrl, { headers: { Range: 'bytes=0-511' } });
254 if (resp.ok || resp.status === 206) {
255 const buf = await resp.arrayBuffer();
256 bodyEl.innerHTML =
257 '<div class="blob-hex-wrap"><pre class="blob-hex">' + renderHexDump(buf) + '</pre></div>'
258 + '<div class="blob-binary-notice">Showing first ' + Math.min(512, sizeBytes)
259 + ' bytes of ' + fmtSize(sizeBytes) + '. '
260 + '<a href="' + escHtml(rawUrl) + '" download>Download full file</a></div>';
261 } else {
262 bodyEl.innerHTML = '<div class="blob-binary-notice">Binary file — <a href="' + escHtml(rawUrl) + '" download>Download</a></div>';
263 }
264 } catch {
265 bodyEl.innerHTML = '<div class="blob-binary-notice">Binary file — <a href="' + escHtml(rawUrl) + '" download>Download</a></div>';
266 }
267 }
268
269 // ── Full client-side render ───────────────────────────────────────────────────
270
271 async function renderBlob(cfg: BlobCfg, data: BlobData): Promise<void> {
272 const rawUrl = data.rawUrl ?? (cfg.base + '/raw/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath);
273 const lang = extToLang(cfg.filename);
274
275 const metaHtml =
276 (data.sizeBytes != null ? '<span title="Size">📄&nbsp;' + fmtSize(data.sizeBytes) + '</span>' : '')
277 + (data.sha ? '<span title="SHA">🔑&nbsp;' + escHtml(shortSha(data.sha)) + '</span>' : '')
278 + (data.createdAt ? '<span title="Last pushed">📅&nbsp;' + escHtml(fmtDate(data.createdAt)) + '</span>' : '')
279 + (lang !== 'plaintext' ? '<span class="blob-lang-badge">' + escHtml(lang) + '</span>' : '');
280
281 const headerHtml =
282 '<div class="blob-header">'
283 + '<div class="blob-filename">📄&nbsp;<code>' + escHtml(data.filename ?? cfg.filename) + '</code></div>'
284 + '<div class="blob-meta">' + metaHtml + '</div>'
285 + '<div class="blob-actions">' + buildActions(cfg, data, rawUrl) + '</div>'
286 + '</div>';
287
288 const syncBody = renderBlobBody(cfg, data, rawUrl);
289 const bodyHtml = '<div class="blob-body" id="blob-body-inner">'
290 + (syncBody !== null ? syncBody : '<div class="blob-loading">Rendering…</div>')
291 + '</div>';
292
293 const contentEl = document.getElementById('content');
294 if (contentEl) contentEl.innerHTML = headerHtml + bodyHtml;
295
296 if (syncBody === null) {
297 const bodyEl = document.getElementById('blob-body-inner');
298 if (bodyEl) await fetchHexPreview(rawUrl, bodyEl, data.sizeBytes ?? 0);
299 }
300 }
301
302 // ── Load metadata from API (client path) ─────────────────────────────────────
303
304 async function loadBlob(cfg: BlobCfg): Promise<void> {
305 // SSR path: server already rendered the blob — add highlighting + line selection.
306 if (cfg.ssrBlobRendered && document.getElementById('blob-ssr-content')) {
307 applySsrHighlighting(cfg.filename);
308 initLineSelection();
309 if (cfg.hasOutline) initOutlinePanel();
310 resolveSymbolHash(cfg.symbolLines);
311 return;
312 }
313
314 // Client path: fetch metadata, then render.
315 const contentEl = document.getElementById('content');
316 if (!contentEl) return;
317 contentEl.innerHTML = '<div class="blob-loading">Loading…</div>';
318
319 try {
320 const headers: Record<string, string> = {};
321 const url = '/api/repos/' + cfg.repoId + '/blob/' + encodeURIComponent(cfg.ref) + '/' + cfg.filePath;
322 const resp = await fetch(url, { headers });
323
324 if (resp.status === 404) {
325 contentEl.innerHTML = '<div class="blob-error">❌ File not found: <code>' + escHtml(cfg.filePath) + '</code></div>';
326 return;
327 }
328 if (resp.status === 401) {
329 contentEl.innerHTML = '<div class="blob-error">🔒 Private repo — sign in to view this file.</div>';
330 return;
331 }
332 if (!resp.ok) {
333 contentEl.innerHTML = '<div class="blob-error">❌ Failed to load file (HTTP ' + resp.status + ').</div>';
334 return;
335 }
336
337 const data = await resp.json() as BlobData;
338 await renderBlob(cfg, data);
339 initLineSelection();
340 if (cfg.hasOutline) initOutlinePanel();
341 resolveSymbolHash(cfg.symbolLines);
342 } catch (err) {
343 const el = document.getElementById('content');
344 if (el) el.innerHTML = '<div class="blob-error">❌ ' + escHtml(String(err)) + '</div>';
345 }
346 }
347
348 // ── Phase 1: Multi-line selection & deep links ────────────────────────────────
349
350 /** Parse "#L10" or "#L10-L25" from location.hash → [start, end] (1-based, inclusive). */
351 function parseLineHash(): [number, number] | null {
352 const hash = location.hash;
353 const single = /^#L(\d+)$/.exec(hash);
354 if (single) { const n = parseInt(single[1], 10); return [n, n]; }
355 const range = /^#L(\d+)-L(\d+)$/.exec(hash);
356 if (range) return [parseInt(range[1], 10), parseInt(range[2], 10)];
357 return null;
358 }
359
360 /** Build URL hash string for a line range. */
361 function lineHash(start: number, end: number): string {
362 return start === end ? `#L${start}` : `#L${start}-L${end}`;
363 }
364
365 /** Apply/remove the selected class to rows in [start, end] (1-based). */
366 function applySelection(start: number, end: number): void {
367 const lo = Math.min(start, end);
368 const hi = Math.max(start, end);
369 document.querySelectorAll<HTMLTableRowElement>('tr.blob2-line').forEach(row => {
370 const id = parseInt(row.id.slice(1), 10); // "L12" → 12
371 if (id >= lo && id <= hi) {
372 row.classList.add('blob2-line--selected');
373 } else {
374 row.classList.remove('blob2-line--selected');
375 }
376 });
377 }
378
379 /** Show or hide the permalink float. */
380 function setPermalinkFloat(visible: boolean): void {
381 const el = document.getElementById('blob2-permalink-float');
382 if (!el) return;
383 if (visible) el.classList.add('is-visible');
384 else el.classList.remove('is-visible');
385 }
386
387 function initLineSelection(): void {
388 let anchorLine: number | null = null;
389
390 // Restore selection from URL on load.
391 const initial = parseLineHash();
392 if (initial) {
393 const [s, e] = initial;
394 anchorLine = s;
395 applySelection(s, e);
396 setPermalinkFloat(true);
397 // Scroll the anchor row into view.
398 const target = document.getElementById(`L${s}`);
399 if (target) target.scrollIntoView({ block: 'start' });
400 }
401
402 // Wire up line-number clicks.
403 document.querySelectorAll<HTMLAnchorElement>('a.blob2-ln-link').forEach(a => {
404 a.addEventListener('click', (e: MouseEvent) => {
405 e.preventDefault();
406 const line = parseInt(a.dataset['line'] ?? '0', 10);
407 if (!line) return;
408
409 if (e.shiftKey && anchorLine !== null) {
410 // Extend selection to range.
411 const lo = Math.min(anchorLine, line);
412 const hi = Math.max(anchorLine, line);
413 applySelection(lo, hi);
414 history.replaceState(null, '', lineHash(lo, hi));
415 setPermalinkFloat(true);
416 } else {
417 // New single-line anchor.
418 anchorLine = line;
419 applySelection(line, line);
420 history.replaceState(null, '', lineHash(line, line));
421 setPermalinkFloat(true);
422 a.scrollIntoView({ block: 'nearest' });
423 }
424 });
425 });
426 }
427
428 // ── Phase 2: Outline panel ────────────────────────────────────────────────────
429
430 /**
431 * Wire the outline toggle button and tab switching inside the panel.
432 * The panel element already exists in the DOM (SSR-rendered when has_outline=true).
433 */
434 /**
435 * Resolve a #S:SymbolName URL fragment to a line anchor.
436 *
437 * When a link arrives from the issue detail page as
438 * /blob/main/file.py#S:compute_snapshot_id, this function looks up the
439 * symbol name in the server-supplied symbolLines map, rewrites the hash to
440 * #Lnn, scrolls that line into view, and applies the line highlight.
441 */
442 function resolveSymbolHash(symbolLines: SymbolLineMap): void {
443 const hash = location.hash;
444 if (!hash.startsWith('#S:')) return;
445 const name = decodeURIComponent(hash.slice(3));
446 const range = symbolLines[name];
447 if (!range) return;
448 const [start, end] = range;
449 // Rewrite the fragment to a canonical range anchor.
450 history.replaceState(null, '', lineHash(start, end));
451 // Scroll the first line into view.
452 const target = document.getElementById(`L${start}`);
453 if (target) target.scrollIntoView({ block: 'start' });
454 // Highlight the full symbol range.
455 applySelection(start, end);
456 setPermalinkFloat(true);
457 }
458
459 function initOutlinePanel(): void {
460 const layout = document.getElementById('blob2-layout');
461 const panel = document.getElementById('blob2-panel');
462 const toggleBtn = document.getElementById('blob2-outline-toggle');
463 if (!layout || !panel || !toggleBtn) return;
464
465 const ICON_MENU = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
466 const ICON_CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
467
468 // Toggle open/close.
469 toggleBtn.addEventListener('click', () => {
470 const open = layout.classList.toggle('blob2-panel-open');
471 toggleBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
472 toggleBtn.innerHTML = (open ? ICON_CLOSE + ' Close' : ICON_MENU + ' Outline');
473 });
474
475 // Tab switching.
476 const tabs = panel.querySelectorAll<HTMLButtonElement>('.blob2-panel-tab');
477 const panes = panel.querySelectorAll<HTMLElement>('.blob2-panel-pane');
478 tabs.forEach(tab => {
479 tab.addEventListener('click', () => {
480 const target = tab.dataset['tab'];
481 tabs.forEach(t => {
482 t.classList.toggle('blob2-panel-tab--active', t === tab);
483 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
484 });
485 panes.forEach(pane => {
486 const show = pane.id === `blob2-pane-${target}`;
487 pane.classList.toggle('blob2-panel-pane--hidden', !show);
488 });
489 });
490 });
491
492 // Copy address button in Info tab.
493 panel.querySelectorAll<HTMLButtonElement>('.blob2-copy-addr').forEach(btn => {
494 btn.addEventListener('click', () => {
495 const text = btn.dataset['copy'] ?? '';
496 if (!text) return;
497 void navigator.clipboard.writeText(text).then(() => {
498 const orig = btn.title;
499 btn.title = 'Copied!';
500 setTimeout(() => { btn.title = orig; }, 1500);
501 });
502 });
503 });
504 }
505
506 // ── Entry point ───────────────────────────────────────────────────────────────
507
508 export function initBlob(data: Record<string, unknown> = {}): void {
509 // Parse symbol name → [startLine, endLine] map from page_json.
510 // Shape: {"compute_snapshot_id": [322, 396], "diff_workdir_vs_snapshot": [397, 450], ...}
511 const rawSymbols = data['symbolLines'];
512 const symbolLines: SymbolLineMap =
513 rawSymbols != null && typeof rawSymbols === 'object' && !Array.isArray(rawSymbols)
514 ? (rawSymbols as SymbolLineMap)
515 : {};
516
517 const cfg: BlobCfg = {
518 repoId: String(data['repoId'] ?? ''),
519 ref: String(data['ref'] ?? ''),
520 filePath: String(data['filePath'] ?? ''),
521 filename: String(data['filename'] ?? ''),
522 owner: String(data['owner'] ?? ''),
523 repoSlug: String(data['repoSlug'] ?? ''),
524 base: String(data['base'] ?? ''),
525 ssrBlobRendered: data['ssrBlobRendered'] === true || data['ssrBlobRendered'] === 'true',
526 hasOutline: data['hasOutline'] === true || data['hasOutline'] === 'true',
527 symbolLines,
528 };
529 if (!cfg.repoId) return;
530 void loadBlob(cfg);
531 }
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 157 days ago