@jhizzard/termdeck 1.6.1 → 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.
@@ -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
@@ -290,7 +321,13 @@ async function onPanelClose(session) {
290
321
  session_id: session.id,
291
322
  sessionType: adapter.sessionType,
292
323
  // Sprint 50 — T2 consumes this via the new memory_items.source_agent column.
293
- source_agent: adapter.name,
324
+ // Sprint 70 T3 — prefer an explicit `adapter.sourceAgent` provenance tag
325
+ // when an adapter declares one (decouples the provenance string from the
326
+ // registry/binary-match `name`); existing adapters omit it and fall back
327
+ // to `name` (behavior unchanged). The antigravity (`agy`) adapter sets
328
+ // sourceAgent:'antigravity'; the session-end hook's normalizeSourceAgent
329
+ // also aliases the binary name `agy` → `antigravity` as a safety net.
330
+ source_agent: adapter.sourceAgent || adapter.name,
294
331
  };
295
332
 
296
333
  _spawnSessionEndHookImpl(hookPath, payload, {
@@ -355,7 +392,10 @@ async function onPanelPeriodicCapture(session) {
355
392
  cwd: session.meta.cwd,
356
393
  session_id: session.id,
357
394
  sessionType: adapter.sessionType,
358
- source_agent: adapter.name,
395
+ // Sprint 70 T3 — same provenance contract as onPanelClose: an explicit
396
+ // adapter.sourceAgent wins, else fall back to adapter.name (unchanged for
397
+ // existing adapters). agy panels' periodic snapshots tag 'antigravity'.
398
+ source_agent: adapter.sourceAgent || adapter.name,
359
399
  // Mode discriminator the hook reads in resolveFiringContext —
360
400
  // distinguishes "TermDeck server periodic capture" from "Claude Code
361
401
  // PreCompact harness fire."
@@ -388,6 +428,50 @@ function _resolvePeriodicCaptureIntervalMs() {
388
428
  return n;
389
429
  }
390
430
 
431
+ // Sprint 70 T1 — best-effort line-buffering wrap for stdout-capture adapters.
432
+ //
433
+ // The LOAD-BEARING capture mechanism is the PTY tee in spawnTerminalSession;
434
+ // this wrap is a RESIDUAL buffering-defense, valuable only for line-buffered
435
+ // C-stdio CLIs and timelier mid-session periodic checkpoints. It is inert for a
436
+ // compiled binary like `agy` (libstdbuf only affects glibc stdio) and a no-op
437
+ // on hosts without a stdbuf-family tool (stock macOS) — the tee captures
438
+ // everything regardless. We PREFER `stdbuf`/`gstdbuf` (GNU coreutils) because
439
+ // it exec()s the target IN PLACE: same controlling TTY, pid preserved, exit
440
+ // code propagated. We deliberately do NOT use `unbuffer` (expect) — it
441
+ // allocates its own pty, producing a double-pty that strips the interactive-TTY
442
+ // context agent CLIs need (Sprint 64 T2 carve-out 2.4 rationale).
443
+ let _stdbufToolCache; // undefined = unprobed, string = tool name, null = none on PATH
444
+ function _defaultLookStdbuf() {
445
+ if (_stdbufToolCache !== undefined) return _stdbufToolCache;
446
+ const { spawnSync } = require('child_process');
447
+ _stdbufToolCache = null;
448
+ for (const name of ['stdbuf', 'gstdbuf']) {
449
+ try {
450
+ const r = spawnSync('/bin/sh', ['-c', `command -v ${name}`], { encoding: 'utf8' });
451
+ if (r && r.status === 0 && typeof r.stdout === 'string' && r.stdout.trim()) {
452
+ _stdbufToolCache = name;
453
+ break;
454
+ }
455
+ } catch (_) { /* try next candidate */ }
456
+ }
457
+ return _stdbufToolCache;
458
+ }
459
+
460
+ // Returns the (possibly rewritten) { binary, args } to hand pty.spawn. No-op
461
+ // unless the adapter declares `capture.mode==='stdout'` AND `capture.unbuffer`.
462
+ // `lookPath` is dependency-injected so tests stay hermetic (no real stdbuf
463
+ // dependence). Resets the memo via `_resetStdbufToolCacheForTesting`.
464
+ function _resolveStdoutCaptureSpawn(binary, args, capture, lookPath = _defaultLookStdbuf) {
465
+ if (!capture || capture.mode !== 'stdout' || !capture.unbuffer) {
466
+ return { binary, args };
467
+ }
468
+ let tool = null;
469
+ try { tool = lookPath(); } catch (_) { tool = null; }
470
+ if (!tool) return { binary, args }; // graceful fallback — bare direct-spawn
471
+ return { binary: tool, args: ['-oL', '-eL', binary, ...args] };
472
+ }
473
+ function _resetStdbufToolCacheForTesting() { _stdbufToolCache = undefined; }
474
+
391
475
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
392
476
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
393
477
  // them here and pass them into the helper. If a module is missing (e.g.
@@ -1271,6 +1355,328 @@ function createServer(config) {
1271
1355
  // the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
1272
1356
  // Returns the Session object regardless of PTY success — status will be
1273
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
+
1274
1680
  function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
1275
1681
  const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
1276
1682
  const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
@@ -1288,6 +1694,17 @@ function createServer(config) {
1288
1694
  role: role || null,
1289
1695
  });
1290
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
+
1291
1708
  if (pty) {
1292
1709
  // Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
1293
1710
  // (1) no command → spawn the default shell interactively
@@ -1361,6 +1778,18 @@ function createServer(config) {
1361
1778
  args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
1362
1779
  }
1363
1780
 
1781
+ // Sprint 70 T1 — stdout-capture adapters (agy) may opt into a best-effort
1782
+ // line-buffering wrap of the direct-spawn. No-op for every other adapter
1783
+ // (none declare `capture`) and for the shell-wrap path. Gated on
1784
+ // directSpawnAdapter because a capture declaration only rides the
1785
+ // exact-binary direct-spawn path. Falls back to the bare binary when no
1786
+ // stdbuf-family tool is on PATH; the PTY tee below captures regardless.
1787
+ if (directSpawnAdapter && directSpawnAdapter.capture) {
1788
+ const wrapped = _resolveStdoutCaptureSpawn(spawnShell, args, directSpawnAdapter.capture);
1789
+ spawnShell = wrapped.binary;
1790
+ args = wrapped.args;
1791
+ }
1792
+
1364
1793
  try {
1365
1794
  // Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
1366
1795
  // the bundled session-end memory hook (`memory-session-end.js`) sees
@@ -1472,10 +1901,43 @@ function createServer(config) {
1472
1901
  }
1473
1902
  } catch (_periodicErr) { /* fail-soft */ }
1474
1903
 
1904
+ // Sprint 70 T1 — initialize the in-flight stdout capture buffer for
1905
+ // adapters that opt in (agy). The tee in term.onData below appends to
1906
+ // it; resolveTranscriptPath materializes it into a tempfile envelope at
1907
+ // panel close + on the periodic-capture tick. Gated on the direct-spawn
1908
+ // adapter's declaration so non-capture panels carry no buffer (zero
1909
+ // overhead; their behavior is byte-for-byte unchanged).
1910
+ if (directSpawnAdapter && directSpawnAdapter.capture
1911
+ && directSpawnAdapter.capture.mode === 'stdout') {
1912
+ const declaredMax = directSpawnAdapter.capture.maxBytes;
1913
+ const maxBytes = (typeof declaredMax === 'number' && declaredMax > 0)
1914
+ ? declaredMax
1915
+ : 4 * 1024 * 1024;
1916
+ session._stdoutCapture = { chunks: [], bytes: 0, maxBytes };
1917
+ }
1918
+
1475
1919
  // PTY output → analyze + broadcast to WebSocket + transcript archive
1476
1920
  term.onData((data) => {
1477
1921
  session.analyzeOutput(data);
1478
1922
 
1923
+ // Sprint 70 T1 — tee PTY output into the in-flight capture buffer for
1924
+ // stdout-capture adapters (agy). Tail-capped: when the buffer exceeds
1925
+ // maxBytes we drop whole chunks from the FRONT, keeping the most
1926
+ // recent conversation (TUI redraws inflate raw bytes far past the
1927
+ // de-chromed content). Best-effort — a capture failure must never
1928
+ // disrupt the load-bearing PTY data path below.
1929
+ const cap = session._stdoutCapture;
1930
+ if (cap) {
1931
+ try {
1932
+ cap.chunks.push(data);
1933
+ cap.bytes += Buffer.byteLength(data, 'utf8');
1934
+ while (cap.bytes > cap.maxBytes && cap.chunks.length > 1) {
1935
+ const dropped = cap.chunks.shift();
1936
+ cap.bytes -= Buffer.byteLength(dropped, 'utf8');
1937
+ }
1938
+ } catch (_capErr) { /* capture is best-effort */ }
1939
+ }
1940
+
1479
1941
  // Send to connected WebSocket
1480
1942
  if (session.ws && session.ws.readyState === 1) {
1481
1943
  session.ws.send(JSON.stringify({ type: 'output', data }));
@@ -1790,8 +2252,14 @@ function createServer(config) {
1790
2252
  const session = sessions.get(req.params.id);
1791
2253
  if (!session) return res.status(404).json({ error: 'Session not found' });
1792
2254
 
1793
- // Kill PTY process
1794
- if (session.pty) {
2255
+ // Sprint 72 T2 — web-chat panels have no PTY to kill. Fire the idempotent
2256
+ // close path (memory capture + periodic-timer cleanup + exit/panel_exited
2257
+ // broadcast + driver detach) — the web-chat analog of term.onExit — before
2258
+ // removing the session from the manager.
2259
+ if (session.meta.type === 'web-chat') {
2260
+ closeWebChatSession(session, { exitCode: 0, signal: 'SIGTERM' });
2261
+ } else if (session.pty) {
2262
+ // Kill PTY process
1795
2263
  try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
1796
2264
  // Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
1797
2265
  // so `safelyResizePty` can short-circuit any resize attempts that arrive
@@ -1814,6 +2282,60 @@ function createServer(config) {
1814
2282
  app.post('/api/sessions/:id/input', (req, res) => {
1815
2283
  const session = sessions.get(req.params.id);
1816
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
+ }
1817
2339
  // Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
1818
2340
  // pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
1819
2341
  // (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
@@ -2021,14 +2543,22 @@ function createServer(config) {
2021
2543
  app.get('/api/sessions/:id/buffer', (req, res) => {
2022
2544
  const session = sessions.get(req.params.id);
2023
2545
  if (!session) return res.status(404).json({ error: 'Session not found' });
2024
- if (session.meta.status === 'exited' || !session.pty) {
2546
+ // Sprint 72 T2 web-chat panels have no PTY by design, so only the
2547
+ // exited check gates them (the `!session.pty` arm is PTY-only). This keeps
2548
+ // the orchestrator's inject-verify poll (status:'thinking' after a submit)
2549
+ // working on a web-chat panel exactly as on a PTY agent panel (seam 4/5).
2550
+ const isWebChat = session.meta.type === 'web-chat';
2551
+ if (session.meta.status === 'exited' || (!isWebChat && !session.pty)) {
2025
2552
  return res.status(404).json({ error: 'Session is exited' });
2026
2553
  }
2554
+ const inFlight = isWebChat
2555
+ ? ((session._webChatInput && session._webChatInput.pending) || '')
2556
+ : (session._inputBuffer || '');
2027
2557
  res.json({
2028
2558
  ok: true,
2029
- pid: session.pty.pid,
2030
- inputBufferLength: (session._inputBuffer || '').length,
2031
- inputBufferPreview: (session._inputBuffer || '').slice(-200),
2559
+ pid: session.pty ? session.pty.pid : (session.pid || null),
2560
+ inputBufferLength: inFlight.length,
2561
+ inputBufferPreview: inFlight.slice(-200),
2032
2562
  lastActivity: session.meta.lastActivity,
2033
2563
  status: session.meta.status,
2034
2564
  statusDetail: session.meta.statusDetail || '',
@@ -2718,12 +3248,37 @@ function createServer(config) {
2718
3248
 
2719
3249
  switch (parsed.type) {
2720
3250
  case 'input':
2721
- if (session.pty && !session.pty._destroyed) {
3251
+ // Sprint 72 T2 — web-chat composer text from the client input box
3252
+ // goes to the driver's inject (type+send), NOT pty.write. Same
3253
+ // two-stage assembler as the POST /input route, so a trailing-`\r`
3254
+ // submits. PTY panels are untouched (the else-branch is verbatim).
3255
+ if (session.meta.type === 'web-chat') {
3256
+ routeWebChatInput(session, parsed.data);
3257
+ } else if (session.pty && !session.pty._destroyed) {
2722
3258
  session.pty.write(parsed.data);
2723
3259
  session.trackInput(parsed.data);
2724
3260
  }
2725
3261
  break;
2726
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
+
2727
3282
  case 'resize':
2728
3283
  // Sprint 60 v1.0.14 — safelyResizePty guards against the
2729
3284
  // pty-reaper-closed-the-fd race that surfaced 25x in Brad's
@@ -3090,4 +3645,11 @@ module.exports = {
3090
3645
  onPanelPeriodicCapture,
3091
3646
  _setSpawnPeriodicCaptureHookImplForTesting,
3092
3647
  _resolvePeriodicCaptureIntervalMs,
3648
+ // Sprint 70 T1 — stdout-capture spawn-wrap resolver (best-effort stdbuf).
3649
+ _resolveStdoutCaptureSpawn,
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,
3093
3655
  };