@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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// web-chat-grok adapter — Sprint 72 T2 (Workstream B)
|
|
2
|
+
//
|
|
3
|
+
// Sixth adapter in the AGENT_ADAPTERS registry (see ./index.js). Unlike every
|
|
4
|
+
// other adapter it is NOT backed by a node-pty child process — a `web-chat`
|
|
5
|
+
// panel is driven by T1's CDP render-bridge against a real, logged-in headful
|
|
6
|
+
// grok.com tab (`packages/web-chat-driver`). The adapter is the seam the
|
|
7
|
+
// TermDeck server (index.js) consumes; the driver is the seam the adapter
|
|
8
|
+
// consumes. See docs/sprint-72-grok-panel/PLANNING.md § "The 8 TermDeck seams".
|
|
9
|
+
//
|
|
10
|
+
// ── Why a distinct adapter from grok.js (the CLI) ────────────────────────────
|
|
11
|
+
// grok.js drives the `grok-dev` CLI (PTY, ~/.grok/grok.db, GROK_MODEL env). This
|
|
12
|
+
// adapter drives grok.com in a browser — the FLAT-RATE (subscription) path to
|
|
13
|
+
// Grok's reasoning model, which the CLI rejects (`reasoningEffort` → HTTP 400;
|
|
14
|
+
// see grok-models.js). Same provider, different runtime + different cost
|
|
15
|
+
// realization, so it is a separate `sessionType:'web-chat'`. Provenance is
|
|
16
|
+
// tagged `sourceAgent:'grok'` this sprint (ORCH zero-touch decision — see the
|
|
17
|
+
// "source_agent attribution" section); a distinct 'grok-web' tag is deferred.
|
|
18
|
+
//
|
|
19
|
+
// ── The one hard constraint: NO node-pty, NO on-disk transcript ──────────────
|
|
20
|
+
// There is no PTY stream and no conversation file on disk. The server seam
|
|
21
|
+
// accumulates each turn (injected prompt + Grok's completed response) into an
|
|
22
|
+
// in-memory buffer `session._webChatTranscript.turns` (`[{role,content}]`).
|
|
23
|
+
// `resolveTranscriptPath` materializes that buffer into a Gemini-shaped JSON
|
|
24
|
+
// envelope tempfile — EXACTLY the agy.js (Sprint 70 T1) pattern — so the
|
|
25
|
+
// bundled hook's `parseAutoDetect`/`parseGeminiJson` ingest it with NO
|
|
26
|
+
// dedicated `TRANSCRIPT_PARSERS['web-chat']` entry. onPanelClose's close→hook
|
|
27
|
+
// path is reused with no second write path. (Mirrors agy's "live source →
|
|
28
|
+
// tempfile envelope → existing hook" decoupling from the hook layer.)
|
|
29
|
+
//
|
|
30
|
+
// ── statusFor is a contract backstop, not the primary status signal ──────────
|
|
31
|
+
// PTY adapters derive status by pattern-matching escape-laden output. A
|
|
32
|
+
// web-chat panel has no escapes; its status is EVENT-driven by the server seam
|
|
33
|
+
// (a prompt was injected ⇒ 'thinking'; T3's completion detector fired ⇒
|
|
34
|
+
// 'idle'). `statusFor(text)` is implemented for contract uniformity + as a
|
|
35
|
+
// text-shape backstop (and is what index.js routes a completed response
|
|
36
|
+
// through), but the load-bearing transitions are wired in index.js off the
|
|
37
|
+
// driver's inject/onComplete events. We deliberately do NOT carry a
|
|
38
|
+
// `patterns.error` (a Grok answer that DISCUSSES an error is not a panel
|
|
39
|
+
// error) — index.js does not route web-chat text through `_detectErrors`.
|
|
40
|
+
//
|
|
41
|
+
// ── source_agent attribution ─────────────────────────────────────────────────
|
|
42
|
+
// `sourceAgent:'grok'` (ORCH decision 2026-06-08, Blocker 3): we reuse the
|
|
43
|
+
// already-allow-listed 'grok' tag so this sprint touches ZERO release-sensitive
|
|
44
|
+
// surface — the bundled hooks in packages/stack-installer/assets/hooks/* (Brad
|
|
45
|
+
// runs the installed copy) stay pristine. The provenance is still accurate
|
|
46
|
+
// (it IS Grok producing the content); it just doesn't yet distinguish web from
|
|
47
|
+
// CLI. A distinct 'grok-web' tag — which WOULD require adding 'grok-web' to the
|
|
48
|
+
// hook's ALLOWED_SOURCE_AGENTS (else normalizeSourceAgent coerces the row to
|
|
49
|
+
// 'claude') + a hook-version-stamp bump + an install refresh — is deferred to a
|
|
50
|
+
// follow-up. onPanelClose emits `adapter.sourceAgent || adapter.name`.
|
|
51
|
+
//
|
|
52
|
+
// Byte-floor note (also deferred with grok-web): the bundled hook skips
|
|
53
|
+
// transcripts < 5 KB unless sessionType is specifically exempted (only
|
|
54
|
+
// 'antigravity' is today). Our materialized envelope is compact, so a SHORT
|
|
55
|
+
// (<5 KB) web-chat session is currently dropped — substantive auditor/worker
|
|
56
|
+
// sessions (the real use case) run well past 5 KB and capture normally. The
|
|
57
|
+
// exemption is a hook edit, so it rides the same deferred follow-up.
|
|
58
|
+
//
|
|
59
|
+
// Contract — see ./claude.js header for the full annotated adapter shape.
|
|
60
|
+
|
|
61
|
+
'use strict';
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// Patterns. A web-chat panel produces no PTY output, so there is intentionally
|
|
65
|
+
// NO `prompt` pattern (would let detectAdapter steal a real PTY panel's output)
|
|
66
|
+
// and NO `error` pattern (chat prose mentioning "Error:" is not a panel error;
|
|
67
|
+
// index.js never runs `_detectErrors` on web-chat text). `thinking` is the one
|
|
68
|
+
// useful text shape: if a completed response somehow still carries Grok's
|
|
69
|
+
// shimmer label, treat it as still-working. Reused conceptually from grok.js.
|
|
70
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const THINKING = /Planning next moves|Generating plan[….]|Answering[….]|\bThinking\b/;
|
|
73
|
+
|
|
74
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// statusFor — text → { status, statusDetail } | null. By the time index.js
|
|
76
|
+
// routes a string here it is a COMPLETED Grok response (the onComplete event
|
|
77
|
+
// already fired), so the dominant outcome is 'idle' (Grok is done, awaiting the
|
|
78
|
+
// next prompt). The thinking branch is a defensive backstop for the unlikely
|
|
79
|
+
// case a streaming/partial chunk is routed through. null on empty/non-string
|
|
80
|
+
// preserves the contract's "leave status untouched" semantics.
|
|
81
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function statusFor(data) {
|
|
84
|
+
if (typeof data !== 'string' || data.length === 0) return null;
|
|
85
|
+
if (THINKING.test(data)) {
|
|
86
|
+
return { status: 'thinking', statusDetail: 'Grok is responding…' };
|
|
87
|
+
}
|
|
88
|
+
return { status: 'idle', statusDetail: 'Ready' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
92
|
+
// parseTranscript — web-chat capture is ALWAYS structured (the server seam
|
|
93
|
+
// builds `[{role,content}]` turns; there is no raw-ANSI path like agy's TUI
|
|
94
|
+
// scrape). Dual-mode for round-trip safety: accept this adapter's own
|
|
95
|
+
// Gemini-shaped envelope `{messages:[{type,content}]}` AND a bare
|
|
96
|
+
// `[{role,content}]` array. Returns [] on empty/garbage (fail-soft parity).
|
|
97
|
+
// Content truncated to 400 chars to match the other adapters' parsers and the
|
|
98
|
+
// hook's summary builder.
|
|
99
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function parseTranscript(raw) {
|
|
102
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
103
|
+
let obj;
|
|
104
|
+
try { obj = JSON.parse(raw); }
|
|
105
|
+
catch (_) { return []; }
|
|
106
|
+
const rows = Array.isArray(obj)
|
|
107
|
+
? obj
|
|
108
|
+
: (obj && Array.isArray(obj.messages) ? obj.messages : null);
|
|
109
|
+
if (!rows) return [];
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const m of rows) {
|
|
112
|
+
if (!m || typeof m !== 'object') continue;
|
|
113
|
+
// Accept both shapes: {role} (array form) and {type} (envelope form). Only
|
|
114
|
+
// 'user'/'assistant' pass in EITHER field; any other value is dropped — same
|
|
115
|
+
// as the bundled hook's parseGeminiJson treats the envelope (we only ever
|
|
116
|
+
// materialize 'user'/'assistant', so this is strictness, not behavior loss).
|
|
117
|
+
let role = null;
|
|
118
|
+
if (m.role === 'user' || m.role === 'assistant') role = m.role;
|
|
119
|
+
else if (m.type === 'user' || m.type === 'assistant') role = m.type;
|
|
120
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
121
|
+
const content = m.content;
|
|
122
|
+
let text = '';
|
|
123
|
+
if (typeof content === 'string') text = content;
|
|
124
|
+
else if (Array.isArray(content)) {
|
|
125
|
+
text = content
|
|
126
|
+
.filter((c) => c && typeof c.text === 'string')
|
|
127
|
+
.map((c) => c.text)
|
|
128
|
+
.join(' ');
|
|
129
|
+
}
|
|
130
|
+
if (text) out.push({ role, content: text.slice(0, 400) });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// resolveTranscriptPath — Sprint 72 T2. No on-disk transcript exists; the
|
|
137
|
+
// server seam accumulates turns into `session._webChatTranscript.turns`. We
|
|
138
|
+
// materialize that into a Gemini-shaped `{messages:[{type,content}]}` tempfile
|
|
139
|
+
// the bundled hook's parseAutoDetect ingests. Returns null when the panel
|
|
140
|
+
// produced no turn so onPanelClose + the periodic-capture timer no-op cleanly.
|
|
141
|
+
//
|
|
142
|
+
// Called by BOTH onPanelClose (once, at close) and onPanelPeriodicCapture
|
|
143
|
+
// (every interval) — each call re-materializes the current buffer so the
|
|
144
|
+
// periodic timer's size-delta throttle sees the transcript grow. Mirrors agy's
|
|
145
|
+
// resolveTranscriptPath exactly (same envelope shape, same tmpfile discipline).
|
|
146
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
// Per-turn content cap. The hook truncates to 400 for the summary, but storing
|
|
149
|
+
// a bit more keeps the envelope useful for any future richer consumer while
|
|
150
|
+
// bounding tempfile size on a long auditor session.
|
|
151
|
+
const MAX_TURN_CHARS = 4000;
|
|
152
|
+
|
|
153
|
+
async function resolveTranscriptPath(session) {
|
|
154
|
+
const fs = require('fs');
|
|
155
|
+
const path = require('path');
|
|
156
|
+
const os = require('os');
|
|
157
|
+
if (!session || !session.meta) return null;
|
|
158
|
+
const buf = session._webChatTranscript;
|
|
159
|
+
if (!buf || !Array.isArray(buf.turns) || buf.turns.length === 0) return null;
|
|
160
|
+
|
|
161
|
+
const messages = [];
|
|
162
|
+
for (const t of buf.turns) {
|
|
163
|
+
if (!t || (t.role !== 'user' && t.role !== 'assistant')) continue;
|
|
164
|
+
const content = typeof t.content === 'string' ? t.content : '';
|
|
165
|
+
if (!content) continue;
|
|
166
|
+
messages.push({ type: t.role, content: content.slice(0, MAX_TURN_CHARS) });
|
|
167
|
+
}
|
|
168
|
+
if (messages.length === 0) return null;
|
|
169
|
+
|
|
170
|
+
const envelope = { messages };
|
|
171
|
+
const safeId = String(session.id || `unknown-${session.pid || ''}`)
|
|
172
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
173
|
+
const tmpfile = path.join(os.tmpdir(), `termdeck-webchat-${safeId}.json`);
|
|
174
|
+
try {
|
|
175
|
+
fs.writeFileSync(tmpfile, JSON.stringify(envelope), 'utf8');
|
|
176
|
+
} catch (_) {
|
|
177
|
+
return null; // fail-soft — a tmpfile write failure must not block teardown
|
|
178
|
+
}
|
|
179
|
+
return tmpfile;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// bootPromptTemplate — a web-chat Grok panel used as a 4+1 lane gets its boot
|
|
184
|
+
// prompt INJECTED into the composer (via the server's two-stage inject seam),
|
|
185
|
+
// not typed into a CLI. Same memory_recall + read-instructional-file + read-
|
|
186
|
+
// sprint-docs scaffold as the Grok CLI adapter; points at AGENTS.md (Grok's
|
|
187
|
+
// project-prompt convention). Contract-complete placeholder.
|
|
188
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
191
|
+
const tn = lane.id || 'T?';
|
|
192
|
+
const sprintNum = sprint.number || '?';
|
|
193
|
+
const sprintName = sprint.name || 'unnamed';
|
|
194
|
+
const project = (lane.project || sprint.project || 'termdeck');
|
|
195
|
+
const briefing = lane.briefingPath || `docs/sprint-${sprintNum}-${sprintName}/${tn}-<lane>.md`;
|
|
196
|
+
const topic = lane.topic || lane.briefingPath || sprintName;
|
|
197
|
+
return [
|
|
198
|
+
`You are ${tn} in Sprint ${sprintNum} (${sprintName}). Boot sequence:`,
|
|
199
|
+
`1. memory_recall(project="${project}", query="${topic}")`,
|
|
200
|
+
`2. memory_recall(query="recent decisions and bugs")`,
|
|
201
|
+
`3. Read ~/.claude/CLAUDE.md and ./AGENTS.md`,
|
|
202
|
+
`4. Read docs/sprint-${sprintNum}-${sprintName}/PLANNING.md`,
|
|
203
|
+
`5. Read docs/sprint-${sprintNum}-${sprintName}/STATUS.md`,
|
|
204
|
+
`6. Read ${briefing}`,
|
|
205
|
+
'',
|
|
206
|
+
'Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in STATUS.md.',
|
|
207
|
+
"Don't bump versions, don't touch CHANGELOG, don't commit.",
|
|
208
|
+
].join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const webChatGrokAdapter = {
|
|
212
|
+
name: 'web-chat-grok',
|
|
213
|
+
sessionType: 'web-chat',
|
|
214
|
+
// ORCH decision 2026-06-08 (Blocker 3): reuse the already-allow-listed 'grok'
|
|
215
|
+
// tag so this sprint touches zero release-sensitive hook surface. A distinct
|
|
216
|
+
// 'grok-web' tag is deferred (needs a hook allowlist edit + version bump).
|
|
217
|
+
// See the "source_agent attribution" header for the full rationale.
|
|
218
|
+
sourceAgent: 'grok',
|
|
219
|
+
// Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
|
|
220
|
+
displayName: 'Grok (Web)',
|
|
221
|
+
// Provider URL the CDP driver navigates the dedicated-profile tab to on
|
|
222
|
+
// attach (T1's `cdp.attach({startUrl})` defaults to about:blank). Provider-
|
|
223
|
+
// owned so a future web-chat-<provider> adapter sets its own. The server seam
|
|
224
|
+
// reads this and passes it through as `startUrl`.
|
|
225
|
+
webChatUrl: 'https://grok.com',
|
|
226
|
+
// CRITICAL: never claim a command-spawned session. web-chat panels are created
|
|
227
|
+
// ONLY via an explicit `type:'web-chat'` on POST /api/sessions — never by
|
|
228
|
+
// output sniffing or command-string match. Returning false here (and carrying
|
|
229
|
+
// no `patterns.prompt` below) guarantees this adapter can never hijack a real
|
|
230
|
+
// PTY panel's detection in detectAdapter()/the direct-spawn loop.
|
|
231
|
+
matches: () => false,
|
|
232
|
+
// No `spawn` block — there is no binary. The direct-spawn loop in index.js is
|
|
233
|
+
// gated on `matches()` (always false here) so it is never reached.
|
|
234
|
+
patterns: {
|
|
235
|
+
// Intentionally NO `prompt` (see matches) and NO `error` (chat prose is not
|
|
236
|
+
// a panel error). Only the thinking shimmer, used by statusFor.
|
|
237
|
+
thinking: THINKING,
|
|
238
|
+
},
|
|
239
|
+
patternNames: {},
|
|
240
|
+
statusFor,
|
|
241
|
+
parseTranscript,
|
|
242
|
+
// 10th adapter field — materializes the in-flight turn buffer into a tempfile
|
|
243
|
+
// envelope (see header). Its PRESENCE is what makes onPanelClose +
|
|
244
|
+
// onPanelPeriodicCapture fire for web-chat panels.
|
|
245
|
+
resolveTranscriptPath,
|
|
246
|
+
bootPromptTemplate,
|
|
247
|
+
// The whole point of the web path: flat-rate subscription, not per-token.
|
|
248
|
+
costBand: 'subscription',
|
|
249
|
+
// N/A for a browser composer — the server seam assembles the 4+1 two-stage
|
|
250
|
+
// paste/submit into a single `grok.inject(handle, fullText)` call, so there is
|
|
251
|
+
// no PTY bracketed-paste handler to be capable-or-not. Declared for contract
|
|
252
|
+
// completeness.
|
|
253
|
+
acceptsPaste: true,
|
|
254
|
+
// No MCP config to auto-wire — grok.com is a browser session, not a CLI with
|
|
255
|
+
// an MCP-server registry file. null = user-managed/none (same as Claude).
|
|
256
|
+
mcpConfig: null,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
module.exports = webChatGrokAdapter;
|