@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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2643
|
+
tw(`${cl}\r\n`);
|
|
2336
2644
|
}
|
|
2337
2645
|
}
|
|
2338
|
-
|
|
2646
|
+
tw(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
|
|
2339
2647
|
} else {
|
|
2340
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1892
|
-
|
|
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
|
-
|
|
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:
|
|
2129
|
-
inputBufferPreview:
|
|
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
|
-
|
|
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
|
};
|