@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.
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +100 -0
- package/packages/cli/src/init-mnestra.js +50 -6
- package/packages/cli/src/init-rumen.js +3 -3
- package/packages/client/public/app.js +341 -30
- package/packages/client/public/index.html +0 -1
- package/packages/client/public/style.css +2 -31
- package/packages/server/src/agent-adapters/agy.js +396 -0
- package/packages/server/src/agent-adapters/gemini.js +309 -42
- package/packages/server/src/agent-adapters/grok-models.js +112 -76
- package/packages/server/src/agent-adapters/index.js +19 -0
- package/packages/server/src/agent-adapters/web-chat-grok.js +259 -0
- package/packages/server/src/index.js +572 -10
- package/packages/server/src/setup/audit-upgrade.js +3 -3
- package/packages/server/src/setup/rumen/functions/graph-inference/index.ts +1 -1
- package/packages/stack-installer/assets/hooks/memory-session-end.js +73 -32
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1794
|
-
|
|
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
|
-
|
|
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:
|
|
2031
|
-
inputBufferPreview:
|
|
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
|
-
|
|
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
|
};
|