@jhizzard/termdeck 1.7.0 → 1.8.0

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.0",
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"
@@ -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
  }
@@ -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
@@ -0,0 +1,259 @@
1
+ // web-chat-grok adapter — Sprint 72 T2 (Workstream B)
2
+ //
3
+ // Sixth adapter in the AGENT_ADAPTERS registry (see ./index.js). Unlike every
4
+ // other adapter it is NOT backed by a node-pty child process — a `web-chat`
5
+ // panel is driven by T1's CDP render-bridge against a real, logged-in headful
6
+ // grok.com tab (`packages/web-chat-driver`). The adapter is the seam the
7
+ // TermDeck server (index.js) consumes; the driver is the seam the adapter
8
+ // consumes. See docs/sprint-72-grok-panel/PLANNING.md § "The 8 TermDeck seams".
9
+ //
10
+ // ── Why a distinct adapter from grok.js (the CLI) ────────────────────────────
11
+ // grok.js drives the `grok-dev` CLI (PTY, ~/.grok/grok.db, GROK_MODEL env). This
12
+ // adapter drives grok.com in a browser — the FLAT-RATE (subscription) path to
13
+ // Grok's reasoning model, which the CLI rejects (`reasoningEffort` → HTTP 400;
14
+ // see grok-models.js). Same provider, different runtime + different cost
15
+ // realization, so it is a separate `sessionType:'web-chat'`. Provenance is
16
+ // tagged `sourceAgent:'grok'` this sprint (ORCH zero-touch decision — see the
17
+ // "source_agent attribution" section); a distinct 'grok-web' tag is deferred.
18
+ //
19
+ // ── The one hard constraint: NO node-pty, NO on-disk transcript ──────────────
20
+ // There is no PTY stream and no conversation file on disk. The server seam
21
+ // accumulates each turn (injected prompt + Grok's completed response) into an
22
+ // in-memory buffer `session._webChatTranscript.turns` (`[{role,content}]`).
23
+ // `resolveTranscriptPath` materializes that buffer into a Gemini-shaped JSON
24
+ // envelope tempfile — EXACTLY the agy.js (Sprint 70 T1) pattern — so the
25
+ // bundled hook's `parseAutoDetect`/`parseGeminiJson` ingest it with NO
26
+ // dedicated `TRANSCRIPT_PARSERS['web-chat']` entry. onPanelClose's close→hook
27
+ // path is reused with no second write path. (Mirrors agy's "live source →
28
+ // tempfile envelope → existing hook" decoupling from the hook layer.)
29
+ //
30
+ // ── statusFor is a contract backstop, not the primary status signal ──────────
31
+ // PTY adapters derive status by pattern-matching escape-laden output. A
32
+ // web-chat panel has no escapes; its status is EVENT-driven by the server seam
33
+ // (a prompt was injected ⇒ 'thinking'; T3's completion detector fired ⇒
34
+ // 'idle'). `statusFor(text)` is implemented for contract uniformity + as a
35
+ // text-shape backstop (and is what index.js routes a completed response
36
+ // through), but the load-bearing transitions are wired in index.js off the
37
+ // driver's inject/onComplete events. We deliberately do NOT carry a
38
+ // `patterns.error` (a Grok answer that DISCUSSES an error is not a panel
39
+ // error) — index.js does not route web-chat text through `_detectErrors`.
40
+ //
41
+ // ── source_agent attribution ─────────────────────────────────────────────────
42
+ // `sourceAgent:'grok'` (ORCH decision 2026-06-08, Blocker 3): we reuse the
43
+ // already-allow-listed 'grok' tag so this sprint touches ZERO release-sensitive
44
+ // surface — the bundled hooks in packages/stack-installer/assets/hooks/* (Brad
45
+ // runs the installed copy) stay pristine. The provenance is still accurate
46
+ // (it IS Grok producing the content); it just doesn't yet distinguish web from
47
+ // CLI. A distinct 'grok-web' tag — which WOULD require adding 'grok-web' to the
48
+ // hook's ALLOWED_SOURCE_AGENTS (else normalizeSourceAgent coerces the row to
49
+ // 'claude') + a hook-version-stamp bump + an install refresh — is deferred to a
50
+ // follow-up. onPanelClose emits `adapter.sourceAgent || adapter.name`.
51
+ //
52
+ // Byte-floor note (also deferred with grok-web): the bundled hook skips
53
+ // transcripts < 5 KB unless sessionType is specifically exempted (only
54
+ // 'antigravity' is today). Our materialized envelope is compact, so a SHORT
55
+ // (<5 KB) web-chat session is currently dropped — substantive auditor/worker
56
+ // sessions (the real use case) run well past 5 KB and capture normally. The
57
+ // exemption is a hook edit, so it rides the same deferred follow-up.
58
+ //
59
+ // Contract — see ./claude.js header for the full annotated adapter shape.
60
+
61
+ 'use strict';
62
+
63
+ // ──────────────────────────────────────────────────────────────────────────
64
+ // Patterns. A web-chat panel produces no PTY output, so there is intentionally
65
+ // NO `prompt` pattern (would let detectAdapter steal a real PTY panel's output)
66
+ // and NO `error` pattern (chat prose mentioning "Error:" is not a panel error;
67
+ // index.js never runs `_detectErrors` on web-chat text). `thinking` is the one
68
+ // useful text shape: if a completed response somehow still carries Grok's
69
+ // shimmer label, treat it as still-working. Reused conceptually from grok.js.
70
+ // ──────────────────────────────────────────────────────────────────────────
71
+
72
+ const THINKING = /Planning next moves|Generating plan[….]|Answering[….]|\bThinking\b/;
73
+
74
+ // ──────────────────────────────────────────────────────────────────────────
75
+ // statusFor — text → { status, statusDetail } | null. By the time index.js
76
+ // routes a string here it is a COMPLETED Grok response (the onComplete event
77
+ // already fired), so the dominant outcome is 'idle' (Grok is done, awaiting the
78
+ // next prompt). The thinking branch is a defensive backstop for the unlikely
79
+ // case a streaming/partial chunk is routed through. null on empty/non-string
80
+ // preserves the contract's "leave status untouched" semantics.
81
+ // ──────────────────────────────────────────────────────────────────────────
82
+
83
+ function statusFor(data) {
84
+ if (typeof data !== 'string' || data.length === 0) return null;
85
+ if (THINKING.test(data)) {
86
+ return { status: 'thinking', statusDetail: 'Grok is responding…' };
87
+ }
88
+ return { status: 'idle', statusDetail: 'Ready' };
89
+ }
90
+
91
+ // ──────────────────────────────────────────────────────────────────────────
92
+ // parseTranscript — web-chat capture is ALWAYS structured (the server seam
93
+ // builds `[{role,content}]` turns; there is no raw-ANSI path like agy's TUI
94
+ // scrape). Dual-mode for round-trip safety: accept this adapter's own
95
+ // Gemini-shaped envelope `{messages:[{type,content}]}` AND a bare
96
+ // `[{role,content}]` array. Returns [] on empty/garbage (fail-soft parity).
97
+ // Content truncated to 400 chars to match the other adapters' parsers and the
98
+ // hook's summary builder.
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+
101
+ function parseTranscript(raw) {
102
+ if (typeof raw !== 'string' || raw.length === 0) return [];
103
+ let obj;
104
+ try { obj = JSON.parse(raw); }
105
+ catch (_) { return []; }
106
+ const rows = Array.isArray(obj)
107
+ ? obj
108
+ : (obj && Array.isArray(obj.messages) ? obj.messages : null);
109
+ if (!rows) return [];
110
+ const out = [];
111
+ for (const m of rows) {
112
+ if (!m || typeof m !== 'object') continue;
113
+ // Accept both shapes: {role} (array form) and {type} (envelope form). Only
114
+ // 'user'/'assistant' pass in EITHER field; any other value is dropped — same
115
+ // as the bundled hook's parseGeminiJson treats the envelope (we only ever
116
+ // materialize 'user'/'assistant', so this is strictness, not behavior loss).
117
+ let role = null;
118
+ if (m.role === 'user' || m.role === 'assistant') role = m.role;
119
+ else if (m.type === 'user' || m.type === 'assistant') role = m.type;
120
+ if (role !== 'user' && role !== 'assistant') continue;
121
+ const content = m.content;
122
+ let text = '';
123
+ if (typeof content === 'string') text = content;
124
+ else if (Array.isArray(content)) {
125
+ text = content
126
+ .filter((c) => c && typeof c.text === 'string')
127
+ .map((c) => c.text)
128
+ .join(' ');
129
+ }
130
+ if (text) out.push({ role, content: text.slice(0, 400) });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────
136
+ // resolveTranscriptPath — Sprint 72 T2. No on-disk transcript exists; the
137
+ // server seam accumulates turns into `session._webChatTranscript.turns`. We
138
+ // materialize that into a Gemini-shaped `{messages:[{type,content}]}` tempfile
139
+ // the bundled hook's parseAutoDetect ingests. Returns null when the panel
140
+ // produced no turn so onPanelClose + the periodic-capture timer no-op cleanly.
141
+ //
142
+ // Called by BOTH onPanelClose (once, at close) and onPanelPeriodicCapture
143
+ // (every interval) — each call re-materializes the current buffer so the
144
+ // periodic timer's size-delta throttle sees the transcript grow. Mirrors agy's
145
+ // resolveTranscriptPath exactly (same envelope shape, same tmpfile discipline).
146
+ // ──────────────────────────────────────────────────────────────────────────
147
+
148
+ // Per-turn content cap. The hook truncates to 400 for the summary, but storing
149
+ // a bit more keeps the envelope useful for any future richer consumer while
150
+ // bounding tempfile size on a long auditor session.
151
+ const MAX_TURN_CHARS = 4000;
152
+
153
+ async function resolveTranscriptPath(session) {
154
+ const fs = require('fs');
155
+ const path = require('path');
156
+ const os = require('os');
157
+ if (!session || !session.meta) return null;
158
+ const buf = session._webChatTranscript;
159
+ if (!buf || !Array.isArray(buf.turns) || buf.turns.length === 0) return null;
160
+
161
+ const messages = [];
162
+ for (const t of buf.turns) {
163
+ if (!t || (t.role !== 'user' && t.role !== 'assistant')) continue;
164
+ const content = typeof t.content === 'string' ? t.content : '';
165
+ if (!content) continue;
166
+ messages.push({ type: t.role, content: content.slice(0, MAX_TURN_CHARS) });
167
+ }
168
+ if (messages.length === 0) return null;
169
+
170
+ const envelope = { messages };
171
+ const safeId = String(session.id || `unknown-${session.pid || ''}`)
172
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
173
+ const tmpfile = path.join(os.tmpdir(), `termdeck-webchat-${safeId}.json`);
174
+ try {
175
+ fs.writeFileSync(tmpfile, JSON.stringify(envelope), 'utf8');
176
+ } catch (_) {
177
+ return null; // fail-soft — a tmpfile write failure must not block teardown
178
+ }
179
+ return tmpfile;
180
+ }
181
+
182
+ // ──────────────────────────────────────────────────────────────────────────
183
+ // bootPromptTemplate — a web-chat Grok panel used as a 4+1 lane gets its boot
184
+ // prompt INJECTED into the composer (via the server's two-stage inject seam),
185
+ // not typed into a CLI. Same memory_recall + read-instructional-file + read-
186
+ // sprint-docs scaffold as the Grok CLI adapter; points at AGENTS.md (Grok's
187
+ // project-prompt convention). Contract-complete placeholder.
188
+ // ──────────────────────────────────────────────────────────────────────────
189
+
190
+ function bootPromptTemplate(lane = {}, sprint = {}) {
191
+ const tn = lane.id || 'T?';
192
+ const sprintNum = sprint.number || '?';
193
+ const sprintName = sprint.name || 'unnamed';
194
+ const project = (lane.project || sprint.project || 'termdeck');
195
+ const briefing = lane.briefingPath || `docs/sprint-${sprintNum}-${sprintName}/${tn}-<lane>.md`;
196
+ const topic = lane.topic || lane.briefingPath || sprintName;
197
+ return [
198
+ `You are ${tn} in Sprint ${sprintNum} (${sprintName}). Boot sequence:`,
199
+ `1. memory_recall(project="${project}", query="${topic}")`,
200
+ `2. memory_recall(query="recent decisions and bugs")`,
201
+ `3. Read ~/.claude/CLAUDE.md and ./AGENTS.md`,
202
+ `4. Read docs/sprint-${sprintNum}-${sprintName}/PLANNING.md`,
203
+ `5. Read docs/sprint-${sprintNum}-${sprintName}/STATUS.md`,
204
+ `6. Read ${briefing}`,
205
+ '',
206
+ 'Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in STATUS.md.',
207
+ "Don't bump versions, don't touch CHANGELOG, don't commit.",
208
+ ].join('\n');
209
+ }
210
+
211
+ const webChatGrokAdapter = {
212
+ name: 'web-chat-grok',
213
+ sessionType: 'web-chat',
214
+ // ORCH decision 2026-06-08 (Blocker 3): reuse the already-allow-listed 'grok'
215
+ // tag so this sprint touches zero release-sensitive hook surface. A distinct
216
+ // 'grok-web' tag is deferred (needs a hook allowlist edit + version bump).
217
+ // See the "source_agent attribution" header for the full rationale.
218
+ sourceAgent: 'grok',
219
+ // Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
220
+ displayName: 'Grok (Web)',
221
+ // Provider URL the CDP driver navigates the dedicated-profile tab to on
222
+ // attach (T1's `cdp.attach({startUrl})` defaults to about:blank). Provider-
223
+ // owned so a future web-chat-<provider> adapter sets its own. The server seam
224
+ // reads this and passes it through as `startUrl`.
225
+ webChatUrl: 'https://grok.com',
226
+ // CRITICAL: never claim a command-spawned session. web-chat panels are created
227
+ // ONLY via an explicit `type:'web-chat'` on POST /api/sessions — never by
228
+ // output sniffing or command-string match. Returning false here (and carrying
229
+ // no `patterns.prompt` below) guarantees this adapter can never hijack a real
230
+ // PTY panel's detection in detectAdapter()/the direct-spawn loop.
231
+ matches: () => false,
232
+ // No `spawn` block — there is no binary. The direct-spawn loop in index.js is
233
+ // gated on `matches()` (always false here) so it is never reached.
234
+ patterns: {
235
+ // Intentionally NO `prompt` (see matches) and NO `error` (chat prose is not
236
+ // a panel error). Only the thinking shimmer, used by statusFor.
237
+ thinking: THINKING,
238
+ },
239
+ patternNames: {},
240
+ statusFor,
241
+ parseTranscript,
242
+ // 10th adapter field — materializes the in-flight turn buffer into a tempfile
243
+ // envelope (see header). Its PRESENCE is what makes onPanelClose +
244
+ // onPanelPeriodicCapture fire for web-chat panels.
245
+ resolveTranscriptPath,
246
+ bootPromptTemplate,
247
+ // The whole point of the web path: flat-rate subscription, not per-token.
248
+ costBand: 'subscription',
249
+ // N/A for a browser composer — the server seam assembles the 4+1 two-stage
250
+ // paste/submit into a single `grok.inject(handle, fullText)` call, so there is
251
+ // no PTY bracketed-paste handler to be capable-or-not. Declared for contract
252
+ // completeness.
253
+ acceptsPaste: true,
254
+ // No MCP config to auto-wire — grok.com is a browser session, not a CLI with
255
+ // an MCP-server registry file. null = user-managed/none (same as Claude).
256
+ mcpConfig: null,
257
+ };
258
+
259
+ module.exports = webChatGrokAdapter;
@@ -104,7 +104,7 @@ const { createGraphRoutes } = require('./graph-routes');
104
104
  const { createProjectsRoutes } = require('./projects-routes');
105
105
  const orchestrationPreview = require('./orchestration-preview');
106
106
  const { createPtyReaper } = require('./pty-reaper');
107
- const { AGENT_ADAPTERS } = require('./agent-adapters');
107
+ const { AGENT_ADAPTERS, getAdapterForSessionType } = require('./agent-adapters');
108
108
  const { deriveRagMode } = require('./rag-mode');
109
109
  const { resolveSpawnShell } = require('./spawn-shell');
110
110
 
@@ -245,6 +245,37 @@ function _setSpawnPeriodicCaptureHookImplForTesting(fn) {
245
245
  _spawnPeriodicCaptureHookImpl = typeof fn === 'function' ? fn : _defaultSpawnPeriodicCaptureHookImpl;
246
246
  }
247
247
 
248
+ // Sprint 72 T2 — web-chat driver resolver (Workstream B). A `web-chat` panel is
249
+ // backed by T1's CDP render-bridge (packages/web-chat-driver), NOT node-pty.
250
+ // Lazy-required + fail-soft: if the driver isn't built/installed yet (T1/T3
251
+ // build it in parallel) the require throws and we return null, so a web-chat
252
+ // spawn degrades to 'errored' status instead of crashing the server — PTY
253
+ // panels AND the parallel Sprint 71 deck stay completely unaffected. The
254
+ // require is by RELATIVE PATH (resolving the package's own package.json `main`),
255
+ // not a root dependency, per Guardrail 5 (no root package.json churn; the
256
+ // driver keeps its own isolated install). Tests inject a fake driver via
257
+ // `_setWebChatDriverImplForTesting` (same DI rationale as the hook-spawn impls
258
+ // above) so the seams are exercised with no real Chrome / CDP / network.
259
+ //
260
+ // Defensive aggregator-gap handling: the driver's src/index.js currently
261
+ // exports only `{ cdp }` (T3's `grok` namespace isn't wired into the aggregator
262
+ // yet — flagged in Sprint 72 STATUS.md). If `.grok` is absent we attach it from
263
+ // the sub-module directly so this seam works before that one-line T1 fix lands.
264
+ function _defaultWebChatDriverImpl() {
265
+ let driver;
266
+ try { driver = require('../../web-chat-driver'); }
267
+ catch (_e) { return null; }
268
+ if (driver && !driver.grok) {
269
+ try { driver = { ...driver, grok: require('../../web-chat-driver/src/grok') }; }
270
+ catch (_e) { /* grok namespace not present yet — cdp-only handle is degraded but non-fatal */ }
271
+ }
272
+ return driver;
273
+ }
274
+ let _webChatDriverImpl = _defaultWebChatDriverImpl;
275
+ function _setWebChatDriverImplForTesting(fn) {
276
+ _webChatDriverImpl = typeof fn === 'function' ? fn : _defaultWebChatDriverImpl;
277
+ }
278
+
248
279
  // Fires when a panel's PTY exits. Routes through the adapter registry's
249
280
  // new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
250
281
  // invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
@@ -1324,6 +1355,328 @@ function createServer(config) {
1324
1355
  // the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
1325
1356
  // Returns the Session object regardless of PTY success — status will be
1326
1357
  // 'errored' if pty.spawn threw.
1358
+ // ────────────────────────────────────────────────────────────────────────
1359
+ // Sprint 72 T2 (Workstream B) — web-chat panel lifecycle.
1360
+ //
1361
+ // A `web-chat` session is driven by T1's CDP render-bridge against a real
1362
+ // grok.com tab, NOT node-pty. These closures are the server seams that
1363
+ // consume the `web-chat-grok` adapter + the driver, reusing the SAME
1364
+ // inject/read/transcript/capture machinery the PTY panels use. The PTY path
1365
+ // (`if (pty)` in spawnTerminalSession) is left byte-identical (Guardrail 3);
1366
+ // everything web-chat is gated on `session.meta.type === 'web-chat'`.
1367
+ // ────────────────────────────────────────────────────────────────────────
1368
+
1369
+ // Per-server panel counter so each web-chat panel gets a distinct CDP port
1370
+ // (T1's profile.js: "T2 allocates a distinct port per panel"). The first
1371
+ // panel uses the canonical 'grok' profile + base port — the warm-login
1372
+ // location the human signs into once. Additional concurrent panels get their
1373
+ // own profile + port (their own Chrome). NOTE: that means panel ≥2 has an
1374
+ // ISOLATED Grok login, not the shared one — the shared-browser-multi-tab
1375
+ // model is a follow-up (flagged in STATUS); single-panel is the sprint scope.
1376
+ let _webChatPanelSeq = 0;
1377
+
1378
+ // Resolve the dedicated profile (NAME → T1's resolveProfileDir maps it to
1379
+ // ~/.termdeck/web-chat-profiles/<name>; an absolute path is used verbatim),
1380
+ // the per-panel CDP port, and the provider start URL (from the adapter).
1381
+ // Posture: never the human's DEFAULT Chrome profile (Chrome 136+ blocks CDP
1382
+ // there anyway). Every value is config/env-overridable.
1383
+ function resolveWebChatProfile(adapter) {
1384
+ const wc = (config && config.webChat) || {};
1385
+ const n = _webChatPanelSeq++;
1386
+ const baseName = wc.profile || process.env.TERMDECK_WEBCHAT_PROFILE || 'grok';
1387
+ const userDataDir = wc.userDataDir
1388
+ || process.env.TERMDECK_WEBCHAT_USER_DATA_DIR
1389
+ || (n === 0 ? baseName : `${baseName}-${n + 1}`);
1390
+ const basePort = parseInt(
1391
+ String(wc.cdpPort || process.env.TERMDECK_WEBCHAT_CDP_PORT || '9333'), 10,
1392
+ );
1393
+ const cdpPort = (Number.isFinite(basePort) ? basePort : 9333) + n;
1394
+ const startUrl = (adapter && adapter.webChatUrl) || wc.startUrl || 'https://grok.com';
1395
+ return { userDataDir, cdpPort, startUrl };
1396
+ }
1397
+
1398
+ // Set status + fire the (best-effort) status-change telemetry. No-op once the
1399
+ // panel is exited so a late driver callback can't resurrect a dead panel.
1400
+ function applyWebChatStatus(session, { status, statusDetail } = {}) {
1401
+ if (!status || session.meta.status === 'exited') return;
1402
+ const oldStatus = session.meta.status;
1403
+ session.meta.status = status;
1404
+ session.meta.statusDetail = statusDetail || '';
1405
+ session.meta.lastActivity = new Date().toISOString();
1406
+ if (oldStatus !== status && session.onStatusChange) {
1407
+ try { session.onStatusChange(session, oldStatus, status); }
1408
+ catch (err) { console.error('[web-chat] onStatusChange error:', err && err.message); }
1409
+ }
1410
+ }
1411
+
1412
+ // Register the periodic-capture timer — web-chat is a non-Claude adapter WITH
1413
+ // resolveTranscriptPath, so it is eligible exactly like a Codex/Gemini/Grok/
1414
+ // agy panel. Replicated from the PTY path (index.js spawn block) rather than
1415
+ // shared so the PTY branch stays byte-identical (Guardrail 3).
1416
+ function maybeRegisterWebChatPeriodicCapture(session) {
1417
+ try {
1418
+ const adapter = getAdapterForSessionType(session.meta.type);
1419
+ const eligible = adapter
1420
+ && adapter.sessionType !== 'claude-code'
1421
+ && typeof adapter.resolveTranscriptPath === 'function';
1422
+ const intervalMs = _resolvePeriodicCaptureIntervalMs();
1423
+ if (eligible && intervalMs > 0) {
1424
+ session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
1425
+ session._periodicCapture.timer = setInterval(() => {
1426
+ onPanelPeriodicCapture(session).catch((err) => {
1427
+ console.error('[periodic-capture] async error:', err && err.message ? err.message : err);
1428
+ });
1429
+ }, intervalMs);
1430
+ if (session._periodicCapture.timer.unref) session._periodicCapture.timer.unref();
1431
+ }
1432
+ } catch (_e) { /* fail-soft */ }
1433
+ }
1434
+
1435
+ // A completed Grok turn (from the driver's onComplete OR a degraded driver's
1436
+ // inject-resolved text): record it, update status via the adapter, broadcast
1437
+ // {type:'output'}, archive to the transcript writer. Deliberately NOT
1438
+ // session.analyzeOutput() — its _detectErrors would false-positive 'errored'
1439
+ // on chat prose containing "Error:" (see web-chat-grok.js header). statusFor
1440
+ // gives the same status outcome without that hazard.
1441
+ function onWebChatResponse(session, responseText) {
1442
+ if (typeof responseText !== 'string' || responseText.length === 0) return;
1443
+ if (session.meta.status === 'exited') return;
1444
+
1445
+ if (session._webChatTranscript && Array.isArray(session._webChatTranscript.turns)) {
1446
+ session._webChatTranscript.turns.push({ role: 'assistant', content: responseText });
1447
+ }
1448
+
1449
+ const adapter = getAdapterForSessionType('web-chat');
1450
+ let applied = false;
1451
+ if (adapter && typeof adapter.statusFor === 'function') {
1452
+ const st = adapter.statusFor(responseText);
1453
+ if (st && st.status) { applyWebChatStatus(session, st); applied = true; }
1454
+ }
1455
+ if (!applied) session.meta.lastActivity = new Date().toISOString();
1456
+
1457
+ if (session.ws && session.ws.readyState === 1) {
1458
+ try { session.ws.send(JSON.stringify({ type: 'output', data: responseText })); }
1459
+ catch (_e) { /* never disrupt */ }
1460
+ }
1461
+ if (transcriptWriter) {
1462
+ try { transcriptWriter.append(session.id, responseText, Buffer.byteLength(responseText, 'utf8')); }
1463
+ catch (_e) { /* never let transcript failures disrupt the data path */ }
1464
+ }
1465
+ }
1466
+
1467
+ // Route injected/typed text to the driver's "type into composer + send",
1468
+ // NOT pty.write. Assembles the 4+1 two-stage submit (paste body buffered,
1469
+ // fired on the lone-`\r`) so the orchestrator inject pattern works UNCHANGED.
1470
+ // Returns a small status object the route maps to HTTP.
1471
+ function routeWebChatInput(session, text) {
1472
+ if (typeof text !== 'string') return { ok: false, code: 'invalid_text' };
1473
+ const wc = session._webChat;
1474
+ if (!wc || !wc.handle || !wc.driver || !wc.driver.grok
1475
+ || typeof wc.driver.grok.inject !== 'function') {
1476
+ return { ok: false, code: 'web_chat_not_ready' };
1477
+ }
1478
+ if (!session._webChatInput) session._webChatInput = { pending: '' };
1479
+
1480
+ // Strip bracketed-paste markers; a trailing CR/LF is the submit signal.
1481
+ // No trailing newline ⇒ accumulate only (the two-stage stage-1 case).
1482
+ const stripped = text.replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '');
1483
+ const m = stripped.match(/^([\s\S]*?)[\r\n]+$/);
1484
+ let content; let doSubmit;
1485
+ if (m) { content = m[1]; doSubmit = true; } else { content = stripped; doSubmit = false; }
1486
+ if (content) session._webChatInput.pending += content;
1487
+ if (!doSubmit) return { ok: true, buffered: true };
1488
+
1489
+ const full = session._webChatInput.pending;
1490
+ session._webChatInput.pending = '';
1491
+ if (!full) return { ok: true, empty: true };
1492
+
1493
+ if (session._webChatTranscript && Array.isArray(session._webChatTranscript.turns)) {
1494
+ session._webChatTranscript.turns.push({ role: 'user', content: full });
1495
+ }
1496
+ // Event-driven status so the orchestrator inject-verify sees 'thinking'
1497
+ // immediately after the submit lands (parity with a PTY agent panel).
1498
+ applyWebChatStatus(session, { status: 'thinking', statusDetail: 'Grok is responding…' });
1499
+
1500
+ try {
1501
+ const p = Promise.resolve(wc.driver.grok.inject(wc.handle, full));
1502
+ if (!wc.unsubscribe) {
1503
+ // onComplete wasn't wired (degraded/cdp-only driver) — pull the reply
1504
+ // from inject's resolved value instead so the turn is still captured.
1505
+ p.then((responseText) => onWebChatResponse(session, responseText))
1506
+ .catch((err) => {
1507
+ console.error('[web-chat] inject failed:', err && err.message ? err.message : err);
1508
+ applyWebChatStatus(session, { status: 'errored', statusDetail: `inject failed: ${err && err.message ? err.message : 'unknown'}` });
1509
+ });
1510
+ } else {
1511
+ // Push model: the onComplete listener handles the reply; just surface
1512
+ // inject errors (double-processing avoided by not consuming the value).
1513
+ p.catch((err) => {
1514
+ console.error('[web-chat] inject failed:', err && err.message ? err.message : err);
1515
+ applyWebChatStatus(session, { status: 'errored', statusDetail: `inject failed: ${err && err.message ? err.message : 'unknown'}` });
1516
+ });
1517
+ }
1518
+ } catch (err) {
1519
+ return { ok: false, code: 'inject_threw', error: err && err.message ? err.message : 'unknown' };
1520
+ }
1521
+ return { ok: true, submitted: true };
1522
+ }
1523
+
1524
+ // The web-chat analog of term.onExit. Idempotent (guarded by
1525
+ // `_webChatClosed`): fires the memory-capture hook (seam 7), clears the
1526
+ // periodic timer, broadcasts exit/panel_exited, and tears down the driver.
1527
+ // Wired into DELETE /api/sessions/:id + the driver disconnect callback.
1528
+ function closeWebChatSession(session, opts = {}) {
1529
+ if (!session || session._webChatClosed) return;
1530
+ session._webChatClosed = true;
1531
+ const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 0;
1532
+ const signal = opts.signal || null;
1533
+
1534
+ session.meta.status = 'exited';
1535
+ session.meta.exitCode = exitCode;
1536
+ session.meta.exitedAt = new Date().toISOString();
1537
+ session.meta.statusDetail = `Closed${signal ? ` (${signal})` : ''}`;
1538
+
1539
+ if (session.ws && session.ws.readyState === 1) {
1540
+ try { session.ws.send(JSON.stringify({ type: 'exit', exitCode, signal })); }
1541
+ catch (_e) { /* fail-soft */ }
1542
+ }
1543
+ try {
1544
+ const exitPayload = JSON.stringify({
1545
+ type: 'panel_exited',
1546
+ sessionId: session.id,
1547
+ exitCode,
1548
+ signal: signal || null,
1549
+ exitedAt: session.meta.exitedAt,
1550
+ });
1551
+ wss.clients.forEach((client) => {
1552
+ if (client.readyState === 1) {
1553
+ try { client.send(exitPayload); }
1554
+ catch (err) { console.error('[ws] panel_exited send failed:', err); }
1555
+ }
1556
+ });
1557
+ } catch (err) {
1558
+ console.error('[ws] panel_exited broadcast failed:', err);
1559
+ }
1560
+
1561
+ // Clear the periodic timer BEFORE the close hook so a tick mid-teardown
1562
+ // can't race onPanelClose (same ordering as the PTY path).
1563
+ if (session._periodicCapture && session._periodicCapture.timer) {
1564
+ try { clearInterval(session._periodicCapture.timer); }
1565
+ catch (_e) { /* fail-soft */ }
1566
+ session._periodicCapture.timer = null;
1567
+ }
1568
+
1569
+ onPanelClose(session).catch((err) => {
1570
+ console.error('[panel-close] async error:', err && err.message ? err.message : err);
1571
+ });
1572
+
1573
+ // Tear down driver listeners + detach the CDP handle (tolerant of whichever
1574
+ // teardown method T1's handle exposes).
1575
+ try {
1576
+ const wc = session._webChat;
1577
+ if (wc) {
1578
+ if (typeof wc.unsubscribe === 'function') { try { wc.unsubscribe(); } catch (_e) { /* fail-soft */ } }
1579
+ const h = wc.handle;
1580
+ if (h && typeof h.close === 'function') { try { h.close(); } catch (_e) { /* fail-soft */ } }
1581
+ else if (h && typeof h.detach === 'function') { try { h.detach(); } catch (_e) { /* fail-soft */ } }
1582
+ else if (wc.driver && wc.driver.cdp && typeof wc.driver.cdp.detach === 'function') {
1583
+ try { wc.driver.cdp.detach(h); } catch (_e) { /* fail-soft */ }
1584
+ }
1585
+ }
1586
+ } catch (_e) { /* fail-soft */ }
1587
+ }
1588
+
1589
+ // Boot a web-chat panel: attach T1's driver fire-and-forget (route stays
1590
+ // sync), wire screencast→WS, completion→capture, disconnect→close. Fail-soft
1591
+ // at every step — a missing/partial/throwing driver degrades the panel to
1592
+ // 'errored', never crashes the server.
1593
+ function setupWebChatSession(session) {
1594
+ session.pty = null;
1595
+ session.pid = null;
1596
+ session.meta.status = 'starting';
1597
+ session.meta.statusDetail = 'Connecting to Grok…';
1598
+
1599
+ const adapter = getAdapterForSessionType('web-chat');
1600
+ const driver = _webChatDriverImpl();
1601
+ if (!driver || !driver.cdp || typeof driver.cdp.attach !== 'function' || !adapter) {
1602
+ session.meta.status = 'errored';
1603
+ session.meta.statusDetail = (!driver || !driver.cdp)
1604
+ ? 'web-chat driver not available'
1605
+ : (!adapter ? 'web-chat adapter not registered' : 'web-chat driver missing cdp.attach');
1606
+ return;
1607
+ }
1608
+
1609
+ // In-flight transcript buffer + two-stage inject assembler state.
1610
+ session._webChatTranscript = { turns: [] };
1611
+ session._webChatInput = { pending: '' };
1612
+ // Best-effort status telemetry parity with PTY panels.
1613
+ session.onStatusChange = (sess, oldStatus, newStatus) => {
1614
+ try { rag.onStatusChanged(sess, oldStatus, newStatus); }
1615
+ catch (_e) { /* telemetry is best-effort */ }
1616
+ };
1617
+
1618
+ maybeRegisterWebChatPeriodicCapture(session);
1619
+
1620
+ const { userDataDir, cdpPort, startUrl } = resolveWebChatProfile(adapter);
1621
+
1622
+ (async () => {
1623
+ let handle;
1624
+ try {
1625
+ handle = await driver.cdp.attach({ userDataDir, port: cdpPort, startUrl });
1626
+ } catch (err) {
1627
+ console.error('[web-chat] attach failed:', err && err.message ? err.message : err);
1628
+ if (session.meta.status !== 'exited') {
1629
+ session.meta.status = 'errored';
1630
+ session.meta.statusDetail = `web-chat attach failed: ${err && err.message ? err.message : 'unknown'}`;
1631
+ }
1632
+ return;
1633
+ }
1634
+ if (session._webChatClosed) {
1635
+ // Panel was deleted during attach — detach immediately, don't wire.
1636
+ try { if (handle && typeof handle.close === 'function') handle.close(); } catch (_e) { /* fail-soft */ }
1637
+ return;
1638
+ }
1639
+ session._webChat = { driver, handle, unsubscribe: null };
1640
+
1641
+ // Screencast → WS canvas frames (T3 paints). Prefer handle-method form
1642
+ // (T1's per-session recommendation); fall back to the standalone form.
1643
+ try {
1644
+ const onFrame = (frame) => {
1645
+ if (session.ws && session.ws.readyState === 1) {
1646
+ try { session.ws.send(JSON.stringify({ type: 'web-chat-frame', frame })); }
1647
+ catch (_e) { /* never disrupt */ }
1648
+ }
1649
+ };
1650
+ if (handle && typeof handle.screencast === 'function') handle.screencast(onFrame);
1651
+ else if (driver.cdp && typeof driver.cdp.screencast === 'function') driver.cdp.screencast(handle, onFrame);
1652
+ } catch (err) {
1653
+ console.error('[web-chat] screencast wiring failed:', err && err.message ? err.message : err);
1654
+ }
1655
+
1656
+ // Completed Grok turn → capture (push model).
1657
+ try {
1658
+ if (driver.grok && typeof driver.grok.onComplete === 'function') {
1659
+ session._webChat.unsubscribe = driver.grok.onComplete(handle, (responseText) => {
1660
+ onWebChatResponse(session, responseText);
1661
+ });
1662
+ }
1663
+ } catch (err) {
1664
+ console.error('[web-chat] onComplete wiring failed:', err && err.message ? err.message : err);
1665
+ }
1666
+
1667
+ // Driver/Chrome disconnect → panel close (web-chat analog of term.onExit).
1668
+ try {
1669
+ if (handle && typeof handle.onDisconnect === 'function') {
1670
+ handle.onDisconnect(() => closeWebChatSession(session, { exitCode: 0, signal: 'disconnect' }));
1671
+ } else if (driver.cdp && typeof driver.cdp.onDisconnect === 'function') {
1672
+ driver.cdp.onDisconnect(handle, () => closeWebChatSession(session, { exitCode: 0, signal: 'disconnect' }));
1673
+ }
1674
+ } catch (_e) { /* optional hook — absence is fine */ }
1675
+
1676
+ applyWebChatStatus(session, { status: 'idle', statusDetail: 'Ready' });
1677
+ })();
1678
+ }
1679
+
1327
1680
  function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
1328
1681
  const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
1329
1682
  const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
@@ -1341,6 +1694,17 @@ function createServer(config) {
1341
1694
  role: role || null,
1342
1695
  });
1343
1696
 
1697
+ // Sprint 72 T2 — web-chat panels are driver-backed, not PTY-backed. Boot
1698
+ // T1's CDP render-bridge (fire-and-forget; `pty` stays null) and return the
1699
+ // session synchronously, exactly as the PTY path returns before the first
1700
+ // onData. setupWebChatSession is fully fail-soft, so this branch can never
1701
+ // crash a spawn — and it sits BEFORE `if (pty)` so a web-chat panel never
1702
+ // touches node-pty.
1703
+ if (session.meta.type === 'web-chat') {
1704
+ setupWebChatSession(session);
1705
+ return session;
1706
+ }
1707
+
1344
1708
  if (pty) {
1345
1709
  // Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
1346
1710
  // (1) no command → spawn the default shell interactively
@@ -1888,8 +2252,14 @@ function createServer(config) {
1888
2252
  const session = sessions.get(req.params.id);
1889
2253
  if (!session) return res.status(404).json({ error: 'Session not found' });
1890
2254
 
1891
- // Kill PTY process
1892
- if (session.pty) {
2255
+ // Sprint 72 T2 — web-chat panels have no PTY to kill. Fire the idempotent
2256
+ // close path (memory capture + periodic-timer cleanup + exit/panel_exited
2257
+ // broadcast + driver detach) — the web-chat analog of term.onExit — before
2258
+ // removing the session from the manager.
2259
+ if (session.meta.type === 'web-chat') {
2260
+ closeWebChatSession(session, { exitCode: 0, signal: 'SIGTERM' });
2261
+ } else if (session.pty) {
2262
+ // Kill PTY process
1893
2263
  try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
1894
2264
  // Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
1895
2265
  // so `safelyResizePty` can short-circuit any resize attempts that arrive
@@ -1912,6 +2282,60 @@ function createServer(config) {
1912
2282
  app.post('/api/sessions/:id/input', (req, res) => {
1913
2283
  const session = sessions.get(req.params.id);
1914
2284
  if (!session) return res.status(404).json({ error: 'Session not found' });
2285
+ // Sprint 72 T2 — web-chat panels have no PTY. Route the inject to the
2286
+ // driver (type into composer + send) BEFORE the `!session.pty` 410 guard
2287
+ // below (which would otherwise reject every web-chat inject as "exited").
2288
+ // Self-contained (own rate-limit + logging + response) so the PTY path
2289
+ // below stays byte-identical (Guardrail 3).
2290
+ if (session.meta.type === 'web-chat') {
2291
+ if (session.meta.status === 'exited' || session._webChatClosed) {
2292
+ const msg = `Panel ${req.params.id} has exited`;
2293
+ return res.status(410).json({
2294
+ ok: false, code: 'panel_exited', error: msg, message: msg,
2295
+ exitCode: session.meta.exitCode ?? null,
2296
+ exitedAt: session.meta.exitedAt || null,
2297
+ });
2298
+ }
2299
+ const { text, source, fromSessionId } = req.body || {};
2300
+ if (typeof text !== 'string') return res.status(400).json({ error: 'Missing text' });
2301
+
2302
+ // Same 10 writes/sec/session rate limit as the PTY path below.
2303
+ const now = Date.now();
2304
+ const bucket = inputRateLimit.get(session.id) || { windowStart: now, count: 0 };
2305
+ if (now - bucket.windowStart >= 1000) { bucket.windowStart = now; bucket.count = 0; }
2306
+ bucket.count += 1;
2307
+ inputRateLimit.set(session.id, bucket);
2308
+ if (bucket.count > 10) return res.status(429).json({ error: 'Rate limit exceeded (10/sec)' });
2309
+
2310
+ const result = routeWebChatInput(session, text);
2311
+ if (!result.ok && result.code !== 'invalid_text') {
2312
+ // Driver not attached yet (or inject threw) — 409 Conflict so the caller
2313
+ // can retry; distinct from 410 (gone) / 400 (bad input).
2314
+ return res.status(409).json({
2315
+ ok: false, code: result.code || 'web_chat_not_ready',
2316
+ error: result.error || 'web-chat panel not ready',
2317
+ });
2318
+ }
2319
+ if (!result.ok) return res.status(400).json({ error: 'Missing text' });
2320
+
2321
+ session.meta.replyCount = (session.meta.replyCount || 0) + 1;
2322
+ const effectiveSource = source || 'user';
2323
+ if (db) {
2324
+ try {
2325
+ const snippet = fromSessionId ? `from:${fromSessionId}` : null;
2326
+ logCommand(db, session.id, text.slice(0, 500), snippet, effectiveSource);
2327
+ } catch (err) {
2328
+ console.error('[db] logCommand (web-chat input) failed:', err);
2329
+ }
2330
+ }
2331
+ return res.json({
2332
+ ok: true,
2333
+ bytes: Buffer.byteLength(text, 'utf8'),
2334
+ replyCount: session.meta.replyCount,
2335
+ buffered: !!result.buffered,
2336
+ submitted: !!result.submitted,
2337
+ });
2338
+ }
1915
2339
  // Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
1916
2340
  // pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
1917
2341
  // (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
@@ -2119,14 +2543,22 @@ function createServer(config) {
2119
2543
  app.get('/api/sessions/:id/buffer', (req, res) => {
2120
2544
  const session = sessions.get(req.params.id);
2121
2545
  if (!session) return res.status(404).json({ error: 'Session not found' });
2122
- if (session.meta.status === 'exited' || !session.pty) {
2546
+ // Sprint 72 T2 web-chat panels have no PTY by design, so only the
2547
+ // exited check gates them (the `!session.pty` arm is PTY-only). This keeps
2548
+ // the orchestrator's inject-verify poll (status:'thinking' after a submit)
2549
+ // working on a web-chat panel exactly as on a PTY agent panel (seam 4/5).
2550
+ const isWebChat = session.meta.type === 'web-chat';
2551
+ if (session.meta.status === 'exited' || (!isWebChat && !session.pty)) {
2123
2552
  return res.status(404).json({ error: 'Session is exited' });
2124
2553
  }
2554
+ const inFlight = isWebChat
2555
+ ? ((session._webChatInput && session._webChatInput.pending) || '')
2556
+ : (session._inputBuffer || '');
2125
2557
  res.json({
2126
2558
  ok: true,
2127
- pid: session.pty.pid,
2128
- inputBufferLength: (session._inputBuffer || '').length,
2129
- inputBufferPreview: (session._inputBuffer || '').slice(-200),
2559
+ pid: session.pty ? session.pty.pid : (session.pid || null),
2560
+ inputBufferLength: inFlight.length,
2561
+ inputBufferPreview: inFlight.slice(-200),
2130
2562
  lastActivity: session.meta.lastActivity,
2131
2563
  status: session.meta.status,
2132
2564
  statusDetail: session.meta.statusDetail || '',
@@ -2816,12 +3248,37 @@ function createServer(config) {
2816
3248
 
2817
3249
  switch (parsed.type) {
2818
3250
  case 'input':
2819
- if (session.pty && !session.pty._destroyed) {
3251
+ // Sprint 72 T2 — web-chat composer text from the client input box
3252
+ // goes to the driver's inject (type+send), NOT pty.write. Same
3253
+ // two-stage assembler as the POST /input route, so a trailing-`\r`
3254
+ // submits. PTY panels are untouched (the else-branch is verbatim).
3255
+ if (session.meta.type === 'web-chat') {
3256
+ routeWebChatInput(session, parsed.data);
3257
+ } else if (session.pty && !session.pty._destroyed) {
2820
3258
  session.pty.write(parsed.data);
2821
3259
  session.trackInput(parsed.data);
2822
3260
  }
2823
3261
  break;
2824
3262
 
3263
+ case 'web-chat-input':
3264
+ // Sprint 72 T2 — raw CDP input-event forwarding for DIRECT human
3265
+ // interaction with the live Grok tab (mouse/keyboard on the
3266
+ // screencast canvas). T3's canvas emits
3267
+ // {type:'web-chat-input', event:<CDP Input.* payload>}; routed to
3268
+ // the driver's sendInput. Never reaches a PTY.
3269
+ if (session.meta.type === 'web-chat' && session._webChat && session._webChat.handle) {
3270
+ const wc = session._webChat;
3271
+ try {
3272
+ if (typeof wc.handle.sendInput === 'function') wc.handle.sendInput(parsed.event);
3273
+ else if (wc.driver && wc.driver.cdp && typeof wc.driver.cdp.sendInput === 'function') {
3274
+ wc.driver.cdp.sendInput(wc.handle, parsed.event);
3275
+ }
3276
+ } catch (err) {
3277
+ console.error('[web-chat] sendInput failed:', err && err.message ? err.message : err);
3278
+ }
3279
+ }
3280
+ break;
3281
+
2825
3282
  case 'resize':
2826
3283
  // Sprint 60 v1.0.14 — safelyResizePty guards against the
2827
3284
  // pty-reaper-closed-the-fd race that surfaced 25x in Brad's
@@ -3191,4 +3648,8 @@ module.exports = {
3191
3648
  // Sprint 70 T1 — stdout-capture spawn-wrap resolver (best-effort stdbuf).
3192
3649
  _resolveStdoutCaptureSpawn,
3193
3650
  _resetStdbufToolCacheForTesting,
3651
+ // Sprint 72 T2 — web-chat driver DI seam. Tests inject a fake driver so the
3652
+ // web-chat seams (spawn/inject/output/status/close/capture) are exercised
3653
+ // with no real Chrome / CDP / network.
3654
+ _setWebChatDriverImplForTesting,
3194
3655
  };