@jhizzard/termdeck 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,399 @@ 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
+ // Render-watchdog: self-heal a wedged web-chat cold-start. On a brand-new
1594
+ // browser profile the first load very occasionally paints nothing (empty
1595
+ // body.innerText) even though attach + screencast are healthy; a full
1596
+ // re-navigation clears it (a reload does NOT). Polls briefly for paint, then
1597
+ // re-navigates up to `attempts` times. Returns true if the page painted (or
1598
+ // we cannot measure — never block readiness on the watchdog itself), false if
1599
+ // it stayed blank. Provider-neutral: "painted" == the body has any visible
1600
+ // text, which empirically separates the white cold-start wedge (innerText
1601
+ // length 0) from a rendered SPA (>0). (Sprint-72 hardening — 2026-06-09.)
1602
+ async function ensureWebChatRendered(session, handle, startUrl, opts = {}) {
1603
+ const settleMs = opts.settleMs || Number(process.env.TERMDECK_WEBCHAT_RENDER_SETTLE_MS) || 8000;
1604
+ const attempts = opts.attempts != null ? opts.attempts
1605
+ : (Number(process.env.TERMDECK_WEBCHAT_RENDER_ATTEMPTS) || 2);
1606
+ const stepMs = opts.stepMs || Number(process.env.TERMDECK_WEBCHAT_RENDER_STEP_MS) || 500;
1607
+ if (!handle || !handle.page || typeof handle.page.evaluate !== 'function') return true;
1608
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1609
+ const painted = async () => {
1610
+ try {
1611
+ return await handle.page.evaluate(
1612
+ () => !!(document && document.body && (document.body.innerText || '').trim().length > 0),
1613
+ );
1614
+ } catch (_e) {
1615
+ return false;
1616
+ }
1617
+ };
1618
+ const settle = async () => {
1619
+ for (let waited = 0; waited < settleMs; waited += stepMs) {
1620
+ if (session._webChatClosed) return true;
1621
+ if (await painted()) return true;
1622
+ await sleep(stepMs);
1623
+ }
1624
+ return painted();
1625
+ };
1626
+ if (await settle()) return true;
1627
+ for (let tries = 1; tries <= attempts; tries++) {
1628
+ if (session._webChatClosed) return true;
1629
+ applyWebChatStatus(session, { status: 'starting', statusDetail: `Recovering blank page (try ${tries}/${attempts})…` });
1630
+ try {
1631
+ if (typeof handle.navigate === 'function') {
1632
+ await handle.navigate(startUrl, { waitUntil: 'domcontentloaded' });
1633
+ } else {
1634
+ await handle.page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
1635
+ }
1636
+ } catch (_e) {
1637
+ /* navigation hiccup — re-check paint anyway */
1638
+ }
1639
+ if (await settle()) return true;
1640
+ }
1641
+ return false;
1642
+ }
1643
+
1644
+ function setupWebChatSession(session) {
1645
+ session.pty = null;
1646
+ session.pid = null;
1647
+ session.meta.status = 'starting';
1648
+ session.meta.statusDetail = 'Connecting to Grok…';
1649
+
1650
+ const adapter = getAdapterForSessionType('web-chat');
1651
+ const driver = _webChatDriverImpl();
1652
+ if (!driver || !driver.cdp || typeof driver.cdp.attach !== 'function' || !adapter) {
1653
+ session.meta.status = 'errored';
1654
+ session.meta.statusDetail = (!driver || !driver.cdp)
1655
+ ? 'web-chat driver not available'
1656
+ : (!adapter ? 'web-chat adapter not registered' : 'web-chat driver missing cdp.attach');
1657
+ return;
1658
+ }
1659
+
1660
+ // In-flight transcript buffer + two-stage inject assembler state.
1661
+ session._webChatTranscript = { turns: [] };
1662
+ session._webChatInput = { pending: '' };
1663
+ // Best-effort status telemetry parity with PTY panels.
1664
+ session.onStatusChange = (sess, oldStatus, newStatus) => {
1665
+ try { rag.onStatusChanged(sess, oldStatus, newStatus); }
1666
+ catch (_e) { /* telemetry is best-effort */ }
1667
+ };
1668
+
1669
+ maybeRegisterWebChatPeriodicCapture(session);
1670
+
1671
+ const { userDataDir, cdpPort, startUrl } = resolveWebChatProfile(adapter);
1672
+
1673
+ (async () => {
1674
+ let handle;
1675
+ try {
1676
+ handle = await driver.cdp.attach({ userDataDir, port: cdpPort, startUrl });
1677
+ } catch (err) {
1678
+ console.error('[web-chat] attach failed:', err && err.message ? err.message : err);
1679
+ if (session.meta.status !== 'exited') {
1680
+ session.meta.status = 'errored';
1681
+ session.meta.statusDetail = `web-chat attach failed: ${err && err.message ? err.message : 'unknown'}`;
1682
+ }
1683
+ return;
1684
+ }
1685
+ if (session._webChatClosed) {
1686
+ // Panel was deleted during attach — detach immediately, don't wire.
1687
+ try { if (handle && typeof handle.close === 'function') handle.close(); } catch (_e) { /* fail-soft */ }
1688
+ return;
1689
+ }
1690
+ session._webChat = { driver, handle, unsubscribe: null };
1691
+
1692
+ // Screencast → WS canvas frames (T3 paints). Prefer handle-method form
1693
+ // (T1's per-session recommendation); fall back to the standalone form.
1694
+ try {
1695
+ const onFrame = (frame) => {
1696
+ if (session.ws && session.ws.readyState === 1) {
1697
+ try { session.ws.send(JSON.stringify({ type: 'web-chat-frame', frame })); }
1698
+ catch (_e) { /* never disrupt */ }
1699
+ }
1700
+ };
1701
+ // Render quality (Sprint-72 hardening, 2026-06-09): pass crisp, Retina-friendly
1702
+ // screencast opts. The driver's bare default was a blurry 1280x800 @ jpeg-q60 — fine
1703
+ // on a 1x display, soft on a 2x Mac (the HiDPI canvas then upscales it). Env-tunable
1704
+ // down for slow links: TERMDECK_WEBCHAT_QUALITY / _MAXW / _MAXH / _FORMAT.
1705
+ const scOpts = {
1706
+ format: process.env.TERMDECK_WEBCHAT_FORMAT || 'jpeg',
1707
+ quality: Number(process.env.TERMDECK_WEBCHAT_QUALITY) || 85,
1708
+ maxWidth: Number(process.env.TERMDECK_WEBCHAT_MAXW) || 2560,
1709
+ maxHeight: Number(process.env.TERMDECK_WEBCHAT_MAXH) || 1600,
1710
+ };
1711
+ if (handle && typeof handle.screencast === 'function') handle.screencast(onFrame, scOpts);
1712
+ else if (driver.cdp && typeof driver.cdp.screencast === 'function') driver.cdp.screencast(handle, onFrame, scOpts);
1713
+ } catch (err) {
1714
+ console.error('[web-chat] screencast wiring failed:', err && err.message ? err.message : err);
1715
+ }
1716
+
1717
+ // Completed Grok turn → capture (push model).
1718
+ try {
1719
+ if (driver.grok && typeof driver.grok.onComplete === 'function') {
1720
+ session._webChat.unsubscribe = driver.grok.onComplete(handle, (responseText) => {
1721
+ onWebChatResponse(session, responseText);
1722
+ });
1723
+ }
1724
+ } catch (err) {
1725
+ console.error('[web-chat] onComplete wiring failed:', err && err.message ? err.message : err);
1726
+ }
1727
+
1728
+ // Driver/Chrome disconnect → panel close (web-chat analog of term.onExit).
1729
+ try {
1730
+ if (handle && typeof handle.onDisconnect === 'function') {
1731
+ handle.onDisconnect(() => closeWebChatSession(session, { exitCode: 0, signal: 'disconnect' }));
1732
+ } else if (driver.cdp && typeof driver.cdp.onDisconnect === 'function') {
1733
+ driver.cdp.onDisconnect(handle, () => closeWebChatSession(session, { exitCode: 0, signal: 'disconnect' }));
1734
+ }
1735
+ } catch (_e) { /* optional hook — absence is fine */ }
1736
+
1737
+ // Self-heal a flaky blank cold-start before declaring the panel Ready.
1738
+ let rendered = true;
1739
+ try {
1740
+ rendered = await ensureWebChatRendered(session, handle, startUrl);
1741
+ } catch (err) {
1742
+ console.error('[web-chat] render-watchdog error:', err && err.message ? err.message : err);
1743
+ }
1744
+ if (session._webChatClosed) return;
1745
+ applyWebChatStatus(session, rendered
1746
+ ? { status: 'idle', statusDetail: 'Ready' }
1747
+ : { status: 'errored', statusDetail: 'page did not render (blank after retries)' });
1748
+ })();
1749
+ }
1750
+
1327
1751
  function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
1328
1752
  const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
1329
1753
  const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
@@ -1341,6 +1765,17 @@ function createServer(config) {
1341
1765
  role: role || null,
1342
1766
  });
1343
1767
 
1768
+ // Sprint 72 T2 — web-chat panels are driver-backed, not PTY-backed. Boot
1769
+ // T1's CDP render-bridge (fire-and-forget; `pty` stays null) and return the
1770
+ // session synchronously, exactly as the PTY path returns before the first
1771
+ // onData. setupWebChatSession is fully fail-soft, so this branch can never
1772
+ // crash a spawn — and it sits BEFORE `if (pty)` so a web-chat panel never
1773
+ // touches node-pty.
1774
+ if (session.meta.type === 'web-chat') {
1775
+ setupWebChatSession(session);
1776
+ return session;
1777
+ }
1778
+
1344
1779
  if (pty) {
1345
1780
  // Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
1346
1781
  // (1) no command → spawn the default shell interactively
@@ -1888,8 +2323,14 @@ function createServer(config) {
1888
2323
  const session = sessions.get(req.params.id);
1889
2324
  if (!session) return res.status(404).json({ error: 'Session not found' });
1890
2325
 
1891
- // Kill PTY process
1892
- if (session.pty) {
2326
+ // Sprint 72 T2 — web-chat panels have no PTY to kill. Fire the idempotent
2327
+ // close path (memory capture + periodic-timer cleanup + exit/panel_exited
2328
+ // broadcast + driver detach) — the web-chat analog of term.onExit — before
2329
+ // removing the session from the manager.
2330
+ if (session.meta.type === 'web-chat') {
2331
+ closeWebChatSession(session, { exitCode: 0, signal: 'SIGTERM' });
2332
+ } else if (session.pty) {
2333
+ // Kill PTY process
1893
2334
  try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
1894
2335
  // Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
1895
2336
  // so `safelyResizePty` can short-circuit any resize attempts that arrive
@@ -1912,6 +2353,60 @@ function createServer(config) {
1912
2353
  app.post('/api/sessions/:id/input', (req, res) => {
1913
2354
  const session = sessions.get(req.params.id);
1914
2355
  if (!session) return res.status(404).json({ error: 'Session not found' });
2356
+ // Sprint 72 T2 — web-chat panels have no PTY. Route the inject to the
2357
+ // driver (type into composer + send) BEFORE the `!session.pty` 410 guard
2358
+ // below (which would otherwise reject every web-chat inject as "exited").
2359
+ // Self-contained (own rate-limit + logging + response) so the PTY path
2360
+ // below stays byte-identical (Guardrail 3).
2361
+ if (session.meta.type === 'web-chat') {
2362
+ if (session.meta.status === 'exited' || session._webChatClosed) {
2363
+ const msg = `Panel ${req.params.id} has exited`;
2364
+ return res.status(410).json({
2365
+ ok: false, code: 'panel_exited', error: msg, message: msg,
2366
+ exitCode: session.meta.exitCode ?? null,
2367
+ exitedAt: session.meta.exitedAt || null,
2368
+ });
2369
+ }
2370
+ const { text, source, fromSessionId } = req.body || {};
2371
+ if (typeof text !== 'string') return res.status(400).json({ error: 'Missing text' });
2372
+
2373
+ // Same 10 writes/sec/session rate limit as the PTY path below.
2374
+ const now = Date.now();
2375
+ const bucket = inputRateLimit.get(session.id) || { windowStart: now, count: 0 };
2376
+ if (now - bucket.windowStart >= 1000) { bucket.windowStart = now; bucket.count = 0; }
2377
+ bucket.count += 1;
2378
+ inputRateLimit.set(session.id, bucket);
2379
+ if (bucket.count > 10) return res.status(429).json({ error: 'Rate limit exceeded (10/sec)' });
2380
+
2381
+ const result = routeWebChatInput(session, text);
2382
+ if (!result.ok && result.code !== 'invalid_text') {
2383
+ // Driver not attached yet (or inject threw) — 409 Conflict so the caller
2384
+ // can retry; distinct from 410 (gone) / 400 (bad input).
2385
+ return res.status(409).json({
2386
+ ok: false, code: result.code || 'web_chat_not_ready',
2387
+ error: result.error || 'web-chat panel not ready',
2388
+ });
2389
+ }
2390
+ if (!result.ok) return res.status(400).json({ error: 'Missing text' });
2391
+
2392
+ session.meta.replyCount = (session.meta.replyCount || 0) + 1;
2393
+ const effectiveSource = source || 'user';
2394
+ if (db) {
2395
+ try {
2396
+ const snippet = fromSessionId ? `from:${fromSessionId}` : null;
2397
+ logCommand(db, session.id, text.slice(0, 500), snippet, effectiveSource);
2398
+ } catch (err) {
2399
+ console.error('[db] logCommand (web-chat input) failed:', err);
2400
+ }
2401
+ }
2402
+ return res.json({
2403
+ ok: true,
2404
+ bytes: Buffer.byteLength(text, 'utf8'),
2405
+ replyCount: session.meta.replyCount,
2406
+ buffered: !!result.buffered,
2407
+ submitted: !!result.submitted,
2408
+ });
2409
+ }
1915
2410
  // Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
1916
2411
  // pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
1917
2412
  // (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
@@ -2119,14 +2614,22 @@ function createServer(config) {
2119
2614
  app.get('/api/sessions/:id/buffer', (req, res) => {
2120
2615
  const session = sessions.get(req.params.id);
2121
2616
  if (!session) return res.status(404).json({ error: 'Session not found' });
2122
- if (session.meta.status === 'exited' || !session.pty) {
2617
+ // Sprint 72 T2 web-chat panels have no PTY by design, so only the
2618
+ // exited check gates them (the `!session.pty` arm is PTY-only). This keeps
2619
+ // the orchestrator's inject-verify poll (status:'thinking' after a submit)
2620
+ // working on a web-chat panel exactly as on a PTY agent panel (seam 4/5).
2621
+ const isWebChat = session.meta.type === 'web-chat';
2622
+ if (session.meta.status === 'exited' || (!isWebChat && !session.pty)) {
2123
2623
  return res.status(404).json({ error: 'Session is exited' });
2124
2624
  }
2625
+ const inFlight = isWebChat
2626
+ ? ((session._webChatInput && session._webChatInput.pending) || '')
2627
+ : (session._inputBuffer || '');
2125
2628
  res.json({
2126
2629
  ok: true,
2127
- pid: session.pty.pid,
2128
- inputBufferLength: (session._inputBuffer || '').length,
2129
- inputBufferPreview: (session._inputBuffer || '').slice(-200),
2630
+ pid: session.pty ? session.pty.pid : (session.pid || null),
2631
+ inputBufferLength: inFlight.length,
2632
+ inputBufferPreview: inFlight.slice(-200),
2130
2633
  lastActivity: session.meta.lastActivity,
2131
2634
  status: session.meta.status,
2132
2635
  statusDetail: session.meta.statusDetail || '',
@@ -2816,12 +3319,37 @@ function createServer(config) {
2816
3319
 
2817
3320
  switch (parsed.type) {
2818
3321
  case 'input':
2819
- if (session.pty && !session.pty._destroyed) {
3322
+ // Sprint 72 T2 — web-chat composer text from the client input box
3323
+ // goes to the driver's inject (type+send), NOT pty.write. Same
3324
+ // two-stage assembler as the POST /input route, so a trailing-`\r`
3325
+ // submits. PTY panels are untouched (the else-branch is verbatim).
3326
+ if (session.meta.type === 'web-chat') {
3327
+ routeWebChatInput(session, parsed.data);
3328
+ } else if (session.pty && !session.pty._destroyed) {
2820
3329
  session.pty.write(parsed.data);
2821
3330
  session.trackInput(parsed.data);
2822
3331
  }
2823
3332
  break;
2824
3333
 
3334
+ case 'web-chat-input':
3335
+ // Sprint 72 T2 — raw CDP input-event forwarding for DIRECT human
3336
+ // interaction with the live Grok tab (mouse/keyboard on the
3337
+ // screencast canvas). T3's canvas emits
3338
+ // {type:'web-chat-input', event:<CDP Input.* payload>}; routed to
3339
+ // the driver's sendInput. Never reaches a PTY.
3340
+ if (session.meta.type === 'web-chat' && session._webChat && session._webChat.handle) {
3341
+ const wc = session._webChat;
3342
+ try {
3343
+ if (typeof wc.handle.sendInput === 'function') wc.handle.sendInput(parsed.event);
3344
+ else if (wc.driver && wc.driver.cdp && typeof wc.driver.cdp.sendInput === 'function') {
3345
+ wc.driver.cdp.sendInput(wc.handle, parsed.event);
3346
+ }
3347
+ } catch (err) {
3348
+ console.error('[web-chat] sendInput failed:', err && err.message ? err.message : err);
3349
+ }
3350
+ }
3351
+ break;
3352
+
2825
3353
  case 'resize':
2826
3354
  // Sprint 60 v1.0.14 — safelyResizePty guards against the
2827
3355
  // pty-reaper-closed-the-fd race that surfaced 25x in Brad's
@@ -3132,6 +3660,24 @@ if (require.main === module) {
3132
3660
  process.on('SIGINT', () => handleShutdown('SIGINT'));
3133
3661
  process.on('SIGTERM', () => handleShutdown('SIGTERM'));
3134
3662
 
3663
+ // Fail-soft crash guards (Sprint-72 hardening, 2026-06-09). One bad async
3664
+ // rejection or uncaught error anywhere — a panel handler, a request, a hook —
3665
+ // must NOT crash the whole server and take every live terminal panel (and the
3666
+ // user's work) down with it. We LOG prominently (per-event ISO timestamp, like
3667
+ // the boot banner, so crash boundaries stay greppable) and keep running. This
3668
+ // trades the small risk of continuing in a degraded state for the much larger
3669
+ // cost of losing every panel; a process supervisor is the backstop if the
3670
+ // process ever truly wedges. Shutdown is exempt — let handleShutdown finish.
3671
+ process.on('unhandledRejection', (reason) => {
3672
+ if (shutdownInProgress) return;
3673
+ const msg = (reason && reason.stack) || (reason && reason.message) || String(reason);
3674
+ console.error(`[server] unhandledRejection (kept alive · ${new Date().toISOString()}):\n${msg}`);
3675
+ });
3676
+ process.on('uncaughtException', (err) => {
3677
+ if (shutdownInProgress) return;
3678
+ console.error(`[server] uncaughtException (kept alive · ${new Date().toISOString()}):\n${(err && err.stack) || err}`);
3679
+ });
3680
+
3135
3681
  server.listen(port, host, () => {
3136
3682
  // Sprint 60 v1.0.14 (Item 5) — per-boot banner with ISO timestamp + PID.
3137
3683
  // Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
@@ -3191,4 +3737,8 @@ module.exports = {
3191
3737
  // Sprint 70 T1 — stdout-capture spawn-wrap resolver (best-effort stdbuf).
3192
3738
  _resolveStdoutCaptureSpawn,
3193
3739
  _resetStdbufToolCacheForTesting,
3740
+ // Sprint 72 T2 — web-chat driver DI seam. Tests inject a fake driver so the
3741
+ // web-chat seams (spawn/inject/output/status/close/capture) are exercised
3742
+ // with no real Chrome / CDP / network.
3743
+ _setWebChatDriverImplForTesting,
3194
3744
  };