@jhizzard/termdeck 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/packages/client/public/app.js +329 -19
- package/packages/server/src/agent-adapters/agy.js +21 -30
- package/packages/server/src/agent-adapters/index.js +12 -0
- package/packages/server/src/agent-adapters/web-chat-grok.js +259 -0
- package/packages/server/src/index.js +558 -8
- package/packages/server/src/sprints/status-parser.js +14 -4
|
@@ -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
|
-
//
|
|
1892
|
-
|
|
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
|
-
|
|
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:
|
|
2129
|
-
inputBufferPreview:
|
|
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
|
-
|
|
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
|
};
|