@jhizzard/termdeck 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -25,13 +25,14 @@
25
25
  "packages/server",
26
26
  "packages/client",
27
27
  "packages/cli",
28
- "packages/stack-installer"
28
+ "packages/stack-installer",
29
+ "packages/mcp-bridge"
29
30
  ],
30
31
  "scripts": {
31
32
  "dev": "node packages/server/src/index.js",
32
33
  "server": "node packages/server/src/index.js",
33
34
  "start": "NODE_ENV=production node packages/cli/src/index.js",
34
- "test": "node --test packages/server/tests/**/*.test.js packages/cli/tests/**/*.test.js packages/stack-installer/tests/**/*.test.js",
35
+ "test": "WEB_CHAT_DRIVER_NO_BROWSER=1 node --test packages/server/tests/**/*.test.js packages/cli/tests/**/*.test.js packages/stack-installer/tests/**/*.test.js packages/mcp-bridge/test/*.test.js packages/web-chat-driver/tests/*.test.js",
35
36
  "install:app": "bash install.sh",
36
37
  "sync-rumen-functions": "bash scripts/sync-rumen-functions.sh",
37
38
  "sync:agents": "node scripts/sync-agent-instructions.js"
@@ -472,6 +472,14 @@
472
472
  // /api/sessions/:id/input, and reply-form targets are unaffected.
473
473
  setupPanelDragDrop(panel);
474
474
 
475
+ // Sprint 72 T3 — web-chat (Grok) panels render a live screencast canvas +
476
+ // an inject input box instead of an xterm. Branch AFTER the shared chrome
477
+ // (header/meta/drawer) is built, then return; the entire xterm path below
478
+ // stays byte-identical for every other panel type.
479
+ if (meta.type === 'web-chat') {
480
+ return mountWebChatPanel(id, sessionData, panel);
481
+ }
482
+
475
483
  // Create xterm.js instance
476
484
  const terminal = new Terminal({
477
485
  fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
@@ -653,6 +661,297 @@
653
661
  return { terminal, ws, fitAddon };
654
662
  }
655
663
 
664
+ // ===== Sprint 72 T3: web-chat (Grok) canvas panel =====
665
+ // A web-chat panel mirrors a real, logged-in headful grok.com tab: T1's CDP
666
+ // screencast frames paint to a <canvas>; an input box injects a prompt into
667
+ // the live composer (T2 routes {type:'input'} → grok.inject); and canvas
668
+ // mouse/wheel events forward back to the tab ({type:'web-chat-input'} →
669
+ // handle.sendInput) so the human can drive it in-deck. The xterm render path
670
+ // (createTerminalPanel, above) is untouched for every non-web-chat panel.
671
+
672
+ function ensureWebChatStyles() {
673
+ if (document.getElementById('wc-styles')) return;
674
+ const style = document.createElement('style');
675
+ style.id = 'wc-styles';
676
+ style.textContent = [
677
+ '.panel-terminal.web-chat-terminal{display:flex;flex-direction:column;height:100%;min-height:0;background:#000;}',
678
+ '.web-chat-stage{flex:1;min-height:0;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#000;}',
679
+ '.web-chat-canvas{display:block;max-width:100%;max-height:100%;outline:none;}',
680
+ '.web-chat-input-bar{display:flex;gap:6px;padding:6px;border-top:1px solid #1f2335;background:#16161e;}',
681
+ '.web-chat-input{flex:1;min-width:0;padding:6px 8px;border:1px solid #1f2335;border-radius:6px;background:#1a1b26;color:#c0caf5;font:13px/1.3 inherit;}',
682
+ '.web-chat-input:focus{outline:none;border-color:#7aa2f7;}',
683
+ '.web-chat-send{padding:6px 14px;border:0;border-radius:6px;background:#7aa2f7;color:#0b0b14;font-weight:600;cursor:pointer;}',
684
+ '.web-chat-send:hover{background:#8fb3ff;}',
685
+ ].join('');
686
+ document.head.appendChild(style);
687
+ }
688
+
689
+ // Build the two WS frames that drive T2's server-side two-stage assembler
690
+ // (routeWebChatInput): (1) the prompt wrapped in bracketed-paste markers —
691
+ // buffered server-side after the markers are stripped — then (2) a lone CR,
692
+ // the submit signal that fires grok.inject on the accumulated text. The
693
+ // wrapper is what makes a MULTI-LINE prompt safe: the server submits on a
694
+ // TRAILING CR, so a naive `text + '\r'` would submit early on an embedded
695
+ // newline. Same shape as the 4+1 orchestrator inject. Pure (vm-extract tested).
696
+ function webChatSubmitFrames(text) {
697
+ return [
698
+ { type: 'input', data: '\x1b[200~' + String(text) + '\x1b[201~' },
699
+ { type: 'input', data: '\r' },
700
+ ];
701
+ }
702
+
703
+ function mountWebChatPanel(id, sessionData, panel) {
704
+ const meta = sessionData.meta;
705
+ ensureWebChatStyles();
706
+
707
+ const container = document.getElementById(`term-${id}`);
708
+ container.classList.add('web-chat-terminal');
709
+ container.innerHTML =
710
+ `<div class="web-chat-stage" id="wc-stage-${id}">` +
711
+ `<canvas class="web-chat-canvas" id="wc-canvas-${id}" tabindex="0"></canvas>` +
712
+ `</div>` +
713
+ `<form class="web-chat-input-bar" id="wc-form-${id}" autocomplete="off">` +
714
+ `<input type="text" class="web-chat-input" id="wc-input-${id}" placeholder="Message Grok… (Enter sends into the live session)">` +
715
+ `<button type="submit" class="web-chat-send" id="wc-send-${id}">send</button>` +
716
+ `</form>`;
717
+
718
+ const stage = document.getElementById(`wc-stage-${id}`);
719
+ const canvas = document.getElementById(`wc-canvas-${id}`);
720
+ const ctx = canvas.getContext('2d');
721
+ const inputEl = document.getElementById(`wc-input-${id}`);
722
+ const formEl = document.getElementById(`wc-form-${id}`);
723
+
724
+ let lastImg = null, lastDevW = 0, lastDevH = 0, map = null;
725
+
726
+ // Paint one screencast frame. T1's frame-channel shape exposes `dataUrl`
727
+ // (drops straight into Image.src) + deviceWidth/deviceHeight (the page
728
+ // viewport in CSS px, needed to map clicks back to page coordinates).
729
+ function paintFrame(frame) {
730
+ if (!frame) return;
731
+ const url = frame.dataUrl || (frame.data ? `data:image/${frame.format || 'jpeg'};base64,${frame.data}` : null);
732
+ if (!url) return;
733
+ const img = new Image();
734
+ img.onload = () => {
735
+ lastImg = img;
736
+ lastDevW = frame.deviceWidth || img.naturalWidth;
737
+ lastDevH = frame.deviceHeight || img.naturalHeight;
738
+ drawLastFrame();
739
+ };
740
+ img.src = url;
741
+ }
742
+
743
+ // Letterbox the last frame into the canvas, tracking the transform so
744
+ // pointer events can be mapped back to page coordinates.
745
+ function drawLastFrame() {
746
+ if (!lastImg) return;
747
+ const rect = stage.getBoundingClientRect();
748
+ const dpr = window.devicePixelRatio || 1;
749
+ const cw = Math.max(1, Math.round(rect.width)), ch = Math.max(1, Math.round(rect.height));
750
+ if (canvas.width !== Math.round(cw * dpr) || canvas.height !== Math.round(ch * dpr)) {
751
+ canvas.width = Math.round(cw * dpr);
752
+ canvas.height = Math.round(ch * dpr);
753
+ canvas.style.width = cw + 'px';
754
+ canvas.style.height = ch + 'px';
755
+ }
756
+ const iw = lastImg.naturalWidth, ih = lastImg.naturalHeight;
757
+ const scale = Math.min(canvas.width / iw, canvas.height / ih);
758
+ const dw = iw * scale, dh = ih * scale;
759
+ const dx = (canvas.width - dw) / 2, dy = (canvas.height - dh) / 2;
760
+ ctx.fillStyle = '#000';
761
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
762
+ ctx.drawImage(lastImg, dx, dy, dw, dh);
763
+ map = { dx, dy, scale, iw, ih, dpr };
764
+ }
765
+
766
+ // canvas client px → page CSS px (or null if the click hit the letterbox).
767
+ function mapToPage(clientX, clientY) {
768
+ if (!map || !lastDevW || !lastDevH) return null;
769
+ const rect = canvas.getBoundingClientRect();
770
+ const px = (clientX - rect.left) * map.dpr, py = (clientY - rect.top) * map.dpr;
771
+ const ix = (px - map.dx) / map.scale, iy = (py - map.dy) / map.scale;
772
+ if (ix < 0 || iy < 0 || ix > map.iw || iy > map.ih) return null;
773
+ return { x: Math.round(ix * (lastDevW / map.iw)), y: Math.round(iy * (lastDevH / map.ih)) };
774
+ }
775
+
776
+ function liveWs() { const e = state.sessions.get(id); return e && e.ws; }
777
+ // Submit a composer prompt via T2's two-stage assembler (bracketed-paste
778
+ // body buffered, then a lone CR fires grok.inject). NOT a single raw
779
+ // {type:'input',data:text} — that has no submit sentinel and buffers
780
+ // server-side forever (T4 FINDING 13:30).
781
+ function sendComposerSubmit(text) {
782
+ const ws = liveWs();
783
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
784
+ for (const frame of webChatSubmitFrames(text)) ws.send(JSON.stringify(frame));
785
+ return true;
786
+ }
787
+ function sendCdpEvent(event) {
788
+ const ws = liveWs();
789
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'web-chat-input', event }));
790
+ }
791
+
792
+ // Input box → submit the prompt into the live composer via the two-stage
793
+ // assembler. Clearing the field is gated on the WS send so a prompt is
794
+ // never silently lost when the socket is down.
795
+ formEl.addEventListener('submit', (e) => {
796
+ e.preventDefault();
797
+ const text = inputEl.value;
798
+ if (!text.trim()) return;
799
+ if (sendComposerSubmit(text)) inputEl.value = '';
800
+ });
801
+ inputEl.addEventListener('focus', () => { panel.classList.add('active-input'); state.focusedId = id; });
802
+ inputEl.addEventListener('blur', () => { panel.classList.remove('active-input'); });
803
+
804
+ // Canvas → forward clicks + wheel to the live tab so the human can drive
805
+ // it in-deck. (Text typing stays in the input box for v1; full keyboard
806
+ // forwarding is a future enhancement to avoid hijacking deck shortcuts.)
807
+ canvas.addEventListener('mousedown', (e) => {
808
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
809
+ canvas.focus(); state.focusedId = id;
810
+ sendCdpEvent({ kind: 'mouse', type: 'mousePressed', x: pt.x, y: pt.y, button: 'left', clickCount: 1 });
811
+ });
812
+ canvas.addEventListener('mouseup', (e) => {
813
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
814
+ sendCdpEvent({ kind: 'mouse', type: 'mouseReleased', x: pt.x, y: pt.y, button: 'left', clickCount: 1 });
815
+ });
816
+ canvas.addEventListener('wheel', (e) => {
817
+ const pt = mapToPage(e.clientX, e.clientY); if (!pt) return;
818
+ e.preventDefault();
819
+ sendCdpEvent({ kind: 'mouse', type: 'mouseWheel', x: pt.x, y: pt.y, deltaX: e.deltaX, deltaY: e.deltaY });
820
+ }, { passive: false });
821
+
822
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
823
+ ws.onmessage = (event) => handleWebChatWsMessage(id, event, paintFrame);
824
+ ws.onclose = (event) => {
825
+ const e2 = state.sessions.get(id); if (!e2) return;
826
+ if (event.code === 4000 || event.code === 4001) return;
827
+ const p = document.getElementById(`panel-${id}`);
828
+ if (p && p.classList.contains('exited')) return;
829
+ const delay = Math.min(1000 * Math.pow(2, (e2._reconnectAttempts || 0)), 10000);
830
+ e2._reconnectAttempts = (e2._reconnectAttempts || 0) + 1;
831
+ if (e2._reconnectAttempts <= 5) setTimeout(() => reconnectWebChat(id), delay);
832
+ else updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
833
+ };
834
+
835
+ // Re-letterbox on panel resize (fitAll() also calls our fitAddon.fit()).
836
+ const resizeObserver = new ResizeObserver(() => { try { drawLastFrame(); } catch (err) { /* ignore */ } });
837
+ resizeObserver.observe(stage);
838
+
839
+ state.sessions.set(id, {
840
+ session: sessionData,
841
+ ws,
842
+ el: panel,
843
+ isWebChat: true,
844
+ canvas, ctx, inputEl,
845
+ paintFrame, drawLastFrame,
846
+ // No xterm. A no-op-ish fitAddon keeps fitAll()/drawer-resize loops safe
847
+ // AND re-letterboxes the canvas on a global re-fit.
848
+ fitAddon: { fit() { drawLastFrame(); } },
849
+ activeTab: 'overview',
850
+ drawerOpen: false,
851
+ commandHistory: [],
852
+ commandsLoaded: false,
853
+ memoryHits: [],
854
+ statusLog: [],
855
+ webChatLog: [],
856
+ lastKnownStatus: meta.status,
857
+ });
858
+
859
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
860
+ setupDrawerListeners(id);
861
+ renderOverviewTab(id);
862
+ renderSwitcher();
863
+ const replyBtn = document.getElementById(`reply-btn-${id}`);
864
+ if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
865
+ refreshAllReplyFormsFor(id);
866
+ refreshPanelIndices();
867
+ revealNewPanelIfFiltered(meta);
868
+ refreshDashboardChrome();
869
+ requestAnimationFrame(() => drawLastFrame());
870
+
871
+ return { ws, canvas };
872
+ }
873
+
874
+ // Shared WS dispatch for web-chat panels (used by both initial mount and
875
+ // reconnect). Mirrors the xterm handler's non-output cases exactly (same
876
+ // downstream fns) but routes screencast frames → canvas and grok text → an
877
+ // in-memory transcript instead of writing to an xterm.
878
+ function handleWebChatWsMessage(id, event, paintFrame) {
879
+ let msg;
880
+ try { msg = JSON.parse(event.data); }
881
+ catch (err) { console.error('[client] web-chat ws parse failed:', err); return; }
882
+ const entry = state.sessions.get(id);
883
+ switch (msg.type) {
884
+ case 'web-chat-frame':
885
+ if (paintFrame) paintFrame(msg.frame);
886
+ else if (entry && entry.paintFrame) entry.paintFrame(msg.frame);
887
+ break;
888
+ case 'output':
889
+ // Grok response text. The canvas is the visual; keep a transcript for
890
+ // potential future drawer rendering. Never write to a (nonexistent) xterm.
891
+ if (entry) { (entry.webChatLog = entry.webChatLog || []).push(msg.data); }
892
+ break;
893
+ case 'meta':
894
+ updatePanelMeta(id, msg.session.meta);
895
+ break;
896
+ case 'proactive_memory':
897
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
898
+ break;
899
+ case 'exit': {
900
+ updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
901
+ const p = document.getElementById(`panel-${id}`);
902
+ if (p) p.classList.add('exited');
903
+ refreshAllReplyFormsFor(id);
904
+ refreshPanelIndices();
905
+ renderSwitcher();
906
+ break;
907
+ }
908
+ case 'panel_exited':
909
+ handlePanelExited(msg.sessionId, msg.exitCode);
910
+ break;
911
+ case 'status_broadcast':
912
+ updateGlobalStats(msg.sessions);
913
+ break;
914
+ case 'config_changed':
915
+ if (msg.config) {
916
+ state.config = { ...state.config, ...msg.config };
917
+ if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
918
+ if (typeof updateRagIndicator === 'function') updateRagIndicator();
919
+ }
920
+ break;
921
+ case 'projects_changed':
922
+ if (msg.projects && state.config) {
923
+ state.config.projects = msg.projects;
924
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
925
+ }
926
+ break;
927
+ }
928
+ }
929
+
930
+ function reconnectWebChat(id) {
931
+ const entry = state.sessions.get(id);
932
+ if (!entry) return;
933
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
934
+ ws.onmessage = (event) => handleWebChatWsMessage(id, event, entry.paintFrame);
935
+ ws.onopen = () => {
936
+ entry._reconnectAttempts = 0;
937
+ entry.ws = ws;
938
+ updatePanelMeta(id, { status: 'active', statusDetail: 'Reconnected' });
939
+ };
940
+ ws.onclose = (event) => {
941
+ const p = document.getElementById(`panel-${id}`);
942
+ if (p && p.classList.contains('exited')) return;
943
+ if (event.code === 4001) {
944
+ updatePanelMeta(id, { status: 'exited', statusDetail: 'Session ended' });
945
+ if (p) p.classList.add('exited');
946
+ return;
947
+ }
948
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
949
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
950
+ if (entry._reconnectAttempts <= 5) setTimeout(() => reconnectWebChat(id), delay);
951
+ else updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
952
+ };
953
+ }
954
+
656
955
  // ===== Sprint 65: project-filter chips + ORCH-panel pin + tile lifecycle =====
657
956
  // Brad's 2026-05-13 v2 spec (BACKLOG § D.5) — three dashboard-reliability
658
957
  // surfaces sharing one client-side lane:
@@ -1738,8 +2037,10 @@
1738
2037
  entry.el.classList.add('primary');
1739
2038
  }
1740
2039
 
1741
- // Focus the xterm textarea (without stealing pointer)
1742
- try { entry.terminal.focus(); } catch (err) { /* ignore */ }
2040
+ // Focus the xterm textarea (without stealing pointer); web-chat panels
2041
+ // focus their inject input instead.
2042
+ if (entry.terminal) { try { entry.terminal.focus(); } catch (err) { /* ignore */ } }
2043
+ else if (entry.inputEl) { try { entry.inputEl.focus(); } catch (err) { /* ignore */ } }
1743
2044
  state.focusedId = id;
1744
2045
 
1745
2046
  // Flash the panel border briefly
@@ -1833,7 +2134,7 @@
1833
2134
  setTimeout(() => {
1834
2135
  try { entry.fitAddon.fit(); } catch (err) { /* ignore */ }
1835
2136
  const ws = entry.ws;
1836
- if (ws && ws.readyState === WebSocket.OPEN) {
2137
+ if (ws && ws.readyState === WebSocket.OPEN && entry.terminal) { // PTY resize only; web-chat has no cols/rows
1837
2138
  ws.send(JSON.stringify({
1838
2139
  type: 'resize',
1839
2140
  cols: entry.terminal.cols,
@@ -2054,6 +2355,8 @@
2054
2355
  const entry = state.sessions.get(id);
2055
2356
  if (entry && entry.terminal) {
2056
2357
  try { entry.terminal.focus(); } catch (err) { /* ignore */ }
2358
+ } else if (entry && entry.inputEl) {
2359
+ try { entry.inputEl.focus(); } catch (err) { /* ignore */ } // web-chat inject box
2057
2360
  }
2058
2361
 
2059
2362
  // Re-fit all visible terminals
@@ -2164,9 +2467,9 @@
2164
2467
 
2165
2468
  const entry = state.sessions.get(id);
2166
2469
  if (entry) {
2167
- entry.terminal.dispose();
2168
- entry.ws.close();
2169
- entry.el.remove();
2470
+ if (entry.terminal) entry.terminal.dispose(); // web-chat panels have no xterm
2471
+ if (entry.ws) entry.ws.close();
2472
+ if (entry.el) entry.el.remove();
2170
2473
  state.sessions.delete(id);
2171
2474
  }
2172
2475
 
@@ -2183,7 +2486,7 @@
2183
2486
  if (!entry) return;
2184
2487
 
2185
2488
  const themeObj = getThemeObject(themeId);
2186
- entry.terminal.options.theme = themeObj;
2489
+ if (entry.terminal) entry.terminal.options.theme = themeObj; // no xterm on web-chat panels
2187
2490
 
2188
2491
  // Persist to server (writes to sessions.theme_override server-side)
2189
2492
  api('PATCH', `/api/sessions/${id}`, { theme: themeId });
@@ -2199,7 +2502,7 @@
2199
2502
  const updated = await api('PATCH', '/api/sessions/' + id, { theme: null });
2200
2503
  const resolved = updated && updated.meta && updated.meta.theme;
2201
2504
  if (!resolved) return;
2202
- entry.terminal.options.theme = getThemeObject(resolved);
2505
+ if (entry.terminal) entry.terminal.options.theme = getThemeObject(resolved);
2203
2506
  const sel = document.getElementById('theme-' + id);
2204
2507
  if (sel && sel.value !== resolved) sel.value = resolved;
2205
2508
  }
@@ -2267,9 +2570,14 @@
2267
2570
  const entry = state.sessions.get(id);
2268
2571
  if (!entry) return;
2269
2572
 
2573
+ // Sprint 72 T3 — web-chat panels have no xterm; route mnestra output to a
2574
+ // no-op writer so askAI still caches Memory-tab hits without crashing on a
2575
+ // missing terminal. (xterm panels render inline exactly as before.)
2576
+ const tw = (s) => { if (entry.terminal) { try { entry.terminal.write(s); } catch (e) { /* ignore */ } } };
2577
+
2270
2578
  // Early return if AI queries are not available
2271
2579
  if (!state.config.aiQueryAvailable) {
2272
- entry.terminal.write(
2580
+ tw(
2273
2581
  '\r\n\x1b[33m[mnestra] AI queries are not available.\x1b[0m\r\n' +
2274
2582
  '\x1b[33mTo enable, add the following to ~/.termdeck/config.yaml:\x1b[0m\r\n' +
2275
2583
  '\x1b[90m rag:\r\n' +
@@ -2292,7 +2600,7 @@
2292
2600
  });
2293
2601
 
2294
2602
  if (result.error) {
2295
- entry.terminal.write(`\r\n\x1b[33m[mnestra] ${result.error}\x1b[0m\r\n`);
2603
+ tw(`\r\n\x1b[33m[mnestra] ${result.error}\x1b[0m\r\n`);
2296
2604
  } else if (result.memories && result.memories.length > 0) {
2297
2605
  // Cache hits for the Memory tab
2298
2606
  if (!entry.memoryHits) entry.memoryHits = [];
@@ -2307,7 +2615,7 @@
2307
2615
  if (entry.drawerOpen && entry.activeTab === 'memory') {
2308
2616
  renderMemoryTab(id);
2309
2617
  }
2310
- const cols = entry.terminal.cols || 80;
2618
+ const cols = (entry.terminal && entry.terminal.cols) || 80;
2311
2619
  const wrap = (text, indent) => {
2312
2620
  const maxW = cols - indent - 2;
2313
2621
  const words = text.split(/\s+/);
@@ -2325,23 +2633,23 @@
2325
2633
  return lines;
2326
2634
  };
2327
2635
 
2328
- entry.terminal.write(`\r\n\x1b[36m━━━ Mnestra: ${result.total} memories found ━━━\x1b[0m\r\n`);
2636
+ tw(`\r\n\x1b[36m━━━ Mnestra: ${result.total} memories found ━━━\x1b[0m\r\n`);
2329
2637
  for (const m of result.memories) {
2330
2638
  const score = m.similarity ? `${(m.similarity * 100).toFixed(0)}%` : '';
2331
2639
  const proj = m.project ? m.project : '';
2332
- entry.terminal.write(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
2640
+ tw(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
2333
2641
  const contentLines = wrap(m.content || '(empty)', 2);
2334
2642
  for (const cl of contentLines) {
2335
- entry.terminal.write(`${cl}\r\n`);
2643
+ tw(`${cl}\r\n`);
2336
2644
  }
2337
2645
  }
2338
- entry.terminal.write(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
2646
+ tw(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
2339
2647
  } else {
2340
- entry.terminal.write(`\r\n\x1b[33m[mnestra] No relevant memories found.\x1b[0m\r\n`);
2648
+ tw(`\r\n\x1b[33m[mnestra] No relevant memories found.\x1b[0m\r\n`);
2341
2649
  }
2342
2650
  } catch (err) {
2343
2651
  console.error('[client] AI query failed:', err);
2344
- entry.terminal.write(`\r\n\x1b[31m[mnestra] Query failed: ${err.message}\x1b[0m\r\n`);
2652
+ tw(`\r\n\x1b[31m[mnestra] Query failed: ${err.message}\x1b[0m\r\n`);
2345
2653
  }
2346
2654
 
2347
2655
  inputEl.value = '';
@@ -3447,6 +3755,7 @@
3447
3755
  'codex': 'Codex CLI',
3448
3756
  'gemini': 'Gemini CLI',
3449
3757
  'grok': 'Grok CLI',
3758
+ 'web-chat': 'Grok (web)',
3450
3759
  'python-server': 'Python Server',
3451
3760
  'one-shot': 'One-shot'
3452
3761
  };
@@ -3542,7 +3851,7 @@
3542
3851
  if (themeSelect && themeSelect.value !== meta.theme) {
3543
3852
  themeSelect.value = meta.theme;
3544
3853
  const entry = state.sessions.get(id);
3545
- if (entry) {
3854
+ if (entry && entry.terminal) { // web-chat panels have no xterm to theme
3546
3855
  entry.terminal.options.theme = getThemeObject(meta.theme);
3547
3856
  }
3548
3857
  }
@@ -5017,7 +5326,8 @@
5017
5326
  : (curIdx - 1 + ids.length) % ids.length;
5018
5327
  const entry = state.sessions.get(ids[next]);
5019
5328
  if (entry) {
5020
- entry.terminal.focus();
5329
+ if (entry.terminal) entry.terminal.focus();
5330
+ else if (entry.inputEl) entry.inputEl.focus(); // web-chat: focus the inject box
5021
5331
  state.focusedId = ids[next];
5022
5332
  }
5023
5333
  }
@@ -309,32 +309,25 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
309
309
  }
310
310
 
311
311
  // ──────────────────────────────────────────────────────────────────────────
312
- // mcpConfig — UNVERIFIED at lane time. The brief specifies the path
313
- // `~/.gemini/antigravity-cli/mcp_config.json`, which does NOT exist on disk yet,
314
- // and agy's `settings.json` carries no `mcpServers` key so agy's actual
315
- // MCP-registry read path could not be confirmed against the binary. Modeled on
316
- // the Gemini-family record shape (`mcpServers.NAME = {command,args,env}`, the
317
- // same schema gemini.js uses) since Antigravity is a Gemini-family CLI. The
318
- // shared mcp-autowire helper would CREATE this file on panel spawn. This is a
319
- // non-load-bearing nicety (auto-wiring Mnestra into agy panels); if a future
320
- // probe shows agy reads MCP from settings.json or another path/shape, correct
321
- // here. Env-key omission discipline matches gemini.js (concrete-or-omit; agy,
322
- // like Claude/Gemini, does not shell-expand `${VAR}` in MCP env).
312
+ // mcpConfig — null (Mnestra MCP auto-wire intentionally OFF for agy).
313
+ // VERIFIED 2026-06-08 (live 4-CLI 360): Antigravity's MCP is NOT file-config-
314
+ // driven agy's MCP servers are managed by its embedded "exa" language-server
315
+ // (RPCs `RefreshMcpServers` / `GetMcpServerStates`; type
316
+ // `gemini.GeminiMCPServerConfig`), not a readable `mcp_config.json`. Ruled out
317
+ // empirically against a LIVE agy panel: a de-secreted mnestra block written to
318
+ // BOTH `~/.gemini/config/mcp_config.json` AND the appDataDir
319
+ // `~/.gemini/antigravity-cli/mcp_config.json` left agy reporting
320
+ // `NO-MNESTRA-TOOL`; `~/.gemini/settings.json` already carries mnestra (gemini
321
+ // reads it) yet agy ignores it; `agy --help` exposes no `mcp` subcommand and
322
+ // `agy plugin list` is empty. A file-based mcpConfig here only targets a dead
323
+ // path, so it is `null` → the shared mcp-autowire helper cleanly skips (exactly
324
+ // the Claude case). Wiring Mnestra into agy is a deferred follow-up via the
325
+ // Antigravity language-server registration mechanism (likely IDE- /
326
+ // `RefreshMcpServers`-driven). This was always a non-load-bearing nicety: agy's
327
+ // PTY panel + the memory CAPTURE path (source_agent=antigravity, Sprint 70) both
328
+ // work; only the agy-side memory READ is deferred.
323
329
  // ──────────────────────────────────────────────────────────────────────────
324
330
 
325
- const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
326
-
327
- function buildMnestraBlock({ secrets } = {}) {
328
- const env = {};
329
- for (const key of MNESTRA_ENV_KEYS) {
330
- const value = secrets && secrets[key];
331
- if (typeof value === 'string' && value.length > 0 && !/^\$\{[^}]*\}$/.test(value)) {
332
- env[key] = value;
333
- }
334
- }
335
- return { mnestra: { command: 'mnestra', args: [], env } };
336
- }
337
-
338
331
  const antigravityAdapter = {
339
332
  name: 'antigravity',
340
333
  sessionType: 'antigravity',
@@ -385,12 +378,10 @@ const antigravityAdapter = {
385
378
  // true (bracketed-paste fast path), flip to false if a lane-time test shows
386
379
  // the TUI input box eats the paste markers.
387
380
  acceptsPaste: true,
388
- mcpConfig: {
389
- path: '~/.gemini/antigravity-cli/mcp_config.json',
390
- format: 'json',
391
- mcpServersKey: 'mcpServers',
392
- mnestraBlock: buildMnestraBlock,
393
- },
381
+ // See the mcpConfig note above — Antigravity MCP is language-server-mediated,
382
+ // not file-config; null so mcp-autowire skips (Claude-style) instead of writing
383
+ // a dead-path file. agy memory READ is a deferred follow-up; CAPTURE works.
384
+ mcpConfig: null,
394
385
  };
395
386
 
396
387
  module.exports = antigravityAdapter;
@@ -19,6 +19,12 @@ const grok = require('./grok');
19
19
  // Sprint 70 T1 — Antigravity CLI (`agy`). Registered under its canonical
20
20
  // adapter name `antigravity` (= source_agent); the binary it matches is `agy`.
21
21
  const antigravity = require('./agy');
22
+ // Sprint 72 T2 — Grok web-chat panel (`type:'web-chat'`). NOT a node-pty agent:
23
+ // driven by the CDP render-bridge (packages/web-chat-driver) against a real
24
+ // grok.com tab. Registered for `getAdapterForSessionType('web-chat')` +
25
+ // onPanelClose/periodic capture only — its `matches:()=>false` + absent
26
+ // `patterns.prompt` mean it never participates in output/command detection.
27
+ const webChatGrok = require('./web-chat-grok');
22
28
 
23
29
  // Keyed by adapter name (NOT session.meta.type — adapters expose their own
24
30
  // `sessionType` field for that mapping). Order is iteration order for the
@@ -32,6 +38,12 @@ const AGENT_ADAPTERS = {
32
38
  // claims that string in the detect loop. agy panels are normally resolved by
33
39
  // exact-binary direct-spawn, not output sniffing, so order is not load-bearing.
34
40
  antigravity,
41
+ // Sprint 72 T2 — web-chat-grok. Order is irrelevant: it carries no
42
+ // `patterns.prompt` and `matches()` returns false, so detectAdapter() + the
43
+ // direct-spawn loop skip it entirely. It is reachable ONLY via
44
+ // getAdapterForSessionType('web-chat') (the `.find(a=>a.sessionType===type)`
45
+ // fallback, since the registry key 'web-chat-grok' ≠ sessionType 'web-chat').
46
+ 'web-chat-grok': webChatGrok,
35
47
  };
36
48
 
37
49
  // Convenience accessor — returns the adapter whose `sessionType` matches the