@jhizzard/termdeck 1.6.1 → 1.7.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.
@@ -0,0 +1,396 @@
1
+ // Antigravity CLI (`agy`) adapter — Sprint 70 T1
2
+ //
3
+ // Fifth adapter in the AGENT_ADAPTERS registry (see ./index.js). Makes an
4
+ // Antigravity panel a first-class TermDeck agent whose transcript is written
5
+ // to Mnestra at panel close, the same lifecycle Claude/Codex/Gemini/Grok get.
6
+ //
7
+ // ── The one hard constraint: NO readable on-disk transcript ──────────────────
8
+ // Antigravity stores conversations as protobuf at
9
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.pb (opaque binary)
10
+ // and a flat prompt-history at
11
+ // ~/.gemini/antigravity-cli/history.jsonl ({display,ts,workspace})
12
+ // — the `.pb` has no readable schema and history.jsonl carries NO assistant
13
+ // turns (same flat-history shape codex.js explicitly rejects). Verified live
14
+ // 2026-06-07 (agy v1.0.0 binary / banner reports 1.0.6). So unlike every other
15
+ // adapter — which resolves a structured transcript FILE and lets the bundled
16
+ // hook parse it — agy has to capture the transcript **in-flight from the PTY
17
+ // stdout stream** and materialize it for the close handler.
18
+ //
19
+ // ── Capture architecture (what's load-bearing vs residual) ───────────────────
20
+ // LOAD-BEARING: the PTY tee. spawnTerminalSession (index.js) tees every PTY
21
+ // chunk into `session._stdoutCapture` when an adapter opts in via
22
+ // `capture.mode === 'stdout'` (this adapter is the first to do so; the other
23
+ // four are unchanged). A PTY is a TTY, so the child flushes on exit and the
24
+ // close-time buffer is lossless — the tee alone satisfies the close-proof.
25
+ // RESIDUAL: the `stdbuf` buffering-defense (capture.unbuffer). agy is a
26
+ // compiled Mach-O binary, so `libstdbuf` (LD_PRELOAD) is inert for it; the
27
+ // wrap is a best-effort, gracefully-degrading layer that only matters for
28
+ // future line-buffered C-stdio capture-mode adapters and for timelier
29
+ // mid-session periodic checkpoints. NOT `unbuffer` (it forks its own pty →
30
+ // double-pty → breaks the interactive-TTY semantics Sprint 64 T2 protects).
31
+ //
32
+ // resolveTranscriptPath reads `session._stdoutCapture`, runs parseTranscript to
33
+ // clean + segment, writes a **Gemini-shaped JSON envelope** to os.tmpdir(), and
34
+ // returns that path — exactly grok.js's "live source → tempfile envelope →
35
+ // existing hook" pattern, so onPanelClose's close→hook path is reused with no
36
+ // second write path. The envelope shape (`{messages:[{type,content}]}`) is what
37
+ // the bundled hook's `parseAutoDetect`/`parseGeminiJson` already consume, so
38
+ // agy rows ingest WITHOUT a dedicated `TRANSCRIPT_PARSERS['antigravity']` entry
39
+ // (decoupling T1 from T3's hook edits; T3 owns only the source_agent allowlist).
40
+ //
41
+ // ── source_agent attribution ─────────────────────────────────────────────────
42
+ // `name: 'antigravity'` is the canonical source_agent — onPanelClose emits
43
+ // `source_agent: adapter.name` (and, post-Sprint-70-T3, `adapter.sourceAgent ||
44
+ // adapter.name`). The explicit `sourceAgent: 'antigravity'` field below is
45
+ // belt-and-suspenders: self-documents intent and survives any future rename of
46
+ // `name`. T3 adds `'antigravity'` to the hook's ALLOWED_SOURCE_AGENTS (+ an
47
+ // `agy → antigravity` alias) so the row isn't coerced to 'claude'.
48
+ //
49
+ // ── Transcript fidelity (honest ceiling) ─────────────────────────────────────
50
+ // Capturing a rich full-screen TUI's stdout yields a FUZZY, RAG-grade transcript
51
+ // — not a verbatim log. agy uses truecolor ANSI, cursor positioning, a brief
52
+ // alt-screen (sign-in spinner: enter `?1049h` → exit `?1049l`), box-drawing
53
+ // rules, Braille spinners, and a slash-command menu. We strip ANSI, collapse
54
+ // carriage-return overdraws, drop box/Braille chrome lines, and de-duplicate
55
+ // redraw frames. The substantive conversation survives (it's embedded for
56
+ // semantic recall); precise per-turn role boundaries are approximate. The clean
57
+ // path is an `agy --print` panel (one-shot, plain CRLF text — verified). Full
58
+ // terminal emulation to perfectly reconstruct turns would be a disproportionate
59
+ // dependency (INSTALLER-PITFALLS Class H); best-effort is the right altitude.
60
+ //
61
+ // Contract — see ./claude.js header for the full annotated adapter shape.
62
+
63
+ 'use strict';
64
+
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+ // Patterns. Best-effort, calibrated against the real interactive capture
67
+ // (2026-06-07). Status detection is NOT load-bearing for the capture proof —
68
+ // follow-up: tune `thinking` against a real model-turn capture (the calibration
69
+ // session exited before the model replied, so the thinking spinner's text label
70
+ // is inferred from the Gemini family, not yet observed verbatim).
71
+ // ──────────────────────────────────────────────────────────────────────────
72
+
73
+ // Idle / prompt indicator. agy renders an input box `> ` and a persistent
74
+ // "Antigravity CLI" banner line. Anchored on the banner (agy-distinctive — avoids
75
+ // agy's prompt regex stealing cross-adapter detection from other panels' `> `
76
+ // output) OR the bare input-box prompt.
77
+ const PROMPT = /Antigravity CLI|^[>❯]\s/m;
78
+
79
+ // Thinking indicator. Antigravity is a Gemini-family CLI (banner: "Gemini 3.5
80
+ // Flash"); mirror gemini/grok's working-state vocabulary. Conservative —
81
+ // word-anchored to avoid prose false positives.
82
+ const THINKING = /\b(Thinking|Generating|Working|Reasoning)\b/;
83
+
84
+ // ──────────────────────────────────────────────────────────────────────────
85
+ // statusFor — best-effort panel status. thinking → idle, first match wins;
86
+ // null leaves meta.status untouched (the contract's "no change" semantics).
87
+ // ──────────────────────────────────────────────────────────────────────────
88
+
89
+ function statusFor(data) {
90
+ if (typeof data !== 'string') return null;
91
+ if (THINKING.test(data)) {
92
+ return { status: 'thinking', statusDetail: 'Antigravity is generating...' };
93
+ }
94
+ if (PROMPT.test(data)) {
95
+ return { status: 'idle', statusDetail: 'Waiting for input' };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // ──────────────────────────────────────────────────────────────────────────
101
+ // Capture cleaning helpers (the raw-TUI path).
102
+ // ──────────────────────────────────────────────────────────────────────────
103
+
104
+ // Strip ANSI/VT control sequences. Order matters: OSC (terminated by BEL or
105
+ // ST) first, then CSI (ESC [ params intermediates final), then any remaining
106
+ // nF/Fe/Fs two-byte escapes, then a catch-all. Verified against the real agy
107
+ // capture (truecolor SGR, cursor moves, alt-screen toggles, bracketed-paste,
108
+ // cursor-shape — all removed cleanly).
109
+ function _stripAnsi(s) {
110
+ return s
111
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '') // OSC … BEL|ST
112
+ .replace(/\x1b[\[\]][\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '') // CSI (+ private)
113
+ .replace(/\x1b[\x20-\x2f]*[\x30-\x7e]/g, '') // nF/Fp/Fe/Fs escapes
114
+ .replace(/\x1b./g, ''); // any other ESC pair
115
+ }
116
+
117
+ // Collapse carriage-return overdraws. agy emits CRLF line endings (verified)
118
+ // AND lone-CR spinner redraws (`⣾…\r⣷…\r⣯…`). Normalize CRLF → LF first, then
119
+ // for each line keep only the text after the LAST lone CR (the final overwrite).
120
+ function _normalizeOverdraw(s) {
121
+ return s
122
+ .replace(/\r\n/g, '\n')
123
+ .split('\n')
124
+ .map((line) => {
125
+ const i = line.lastIndexOf('\r');
126
+ return i >= 0 ? line.slice(i + 1) : line;
127
+ })
128
+ .join('\n');
129
+ }
130
+
131
+ // A line is "chrome" (drop it) when it's dominated by box-drawing (U+2500–257F)
132
+ // or Braille (U+2800–28FF, the spinner block) glyphs. ASCII rules like markdown
133
+ // `---`/`***` use hyphen/asterisk (NOT box-drawing), so real markdown content is
134
+ // never caught here. Threshold 0.5 keeps short mixed lines that carry real text.
135
+ function _isChromeLine(line) {
136
+ const stripped = line.replace(/\s/g, '');
137
+ if (stripped.length === 0) return true;
138
+ let glyphs = 0;
139
+ for (const ch of stripped) {
140
+ const cp = ch.codePointAt(0);
141
+ if ((cp >= 0x2500 && cp <= 0x257f) || (cp >= 0x2800 && cp <= 0x28ff)) glyphs += 1;
142
+ }
143
+ return glyphs / stripped.length >= 0.5;
144
+ }
145
+
146
+ // Raw PTY/TUI capture → [{role, content}]. Strip → normalize → de-chrome →
147
+ // de-duplicate consecutive redraw frames → segment. Role attribution is
148
+ // best-effort: default 'assistant' (the bulk of substantive TUI text is model
149
+ // output); a line that is the echo of a typed prompt (sits on/after the `> `
150
+ // input box) is marked 'user'. Each emitted record is truncated to 400 chars to
151
+ // match the other adapters' parsers and the hook's summary builder.
152
+ function _cleanAndSegment(raw) {
153
+ const cleaned = _normalizeOverdraw(_stripAnsi(raw))
154
+ // Drop remaining C0 controls except newline/tab (e.g. BEL, backspace, EOT).
155
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
156
+
157
+ const out = [];
158
+ let prevKept = null;
159
+ let pendingUser = false;
160
+ for (let line of cleaned.split('\n')) {
161
+ line = line.replace(/\s+$/g, '');
162
+ const trimmed = line.trim();
163
+ if (!trimmed) { prevKept = null; continue; }
164
+ if (_isChromeLine(line)) { prevKept = null; continue; }
165
+
166
+ // A lone `>` (or `❯`) is the empty input box — the NEXT substantive line is
167
+ // the user's typed/echoed prompt. Don't emit the marker itself.
168
+ if (/^[>❯]\s*$/.test(trimmed)) { pendingUser = true; prevKept = null; continue; }
169
+
170
+ // Collapse consecutive identical lines (alt-screen / redraw duplication).
171
+ if (trimmed === prevKept) continue;
172
+ prevKept = trimmed;
173
+
174
+ // `> text` on one line: the text after the prompt glyph is user input.
175
+ const promptInline = trimmed.match(/^[>❯]\s+(.+)$/);
176
+ if (promptInline) {
177
+ out.push({ role: 'user', content: promptInline[1].slice(0, 400) });
178
+ pendingUser = false;
179
+ continue;
180
+ }
181
+ const role = pendingUser ? 'user' : 'assistant';
182
+ pendingUser = false;
183
+ out.push({ role, content: trimmed.slice(0, 400) });
184
+ }
185
+ return out;
186
+ }
187
+
188
+ // Structured fast-path: my own resolveTranscriptPath writes a Gemini-shaped
189
+ // `{messages:[{type,content}]}` envelope; also accept a bare `[{role,content}]`
190
+ // array. Lets parseTranscript round-trip its own output, so a hook that calls
191
+ // THIS function on the tempfile (rather than parseAutoDetect) still works.
192
+ function _parseStructured(raw) {
193
+ let obj;
194
+ try { obj = JSON.parse(raw); } catch (_) { return []; }
195
+ const rows = Array.isArray(obj) ? obj : (obj && Array.isArray(obj.messages) ? obj.messages : null);
196
+ if (!rows) return [];
197
+ const out = [];
198
+ for (const m of rows) {
199
+ if (!m || typeof m !== 'object') continue;
200
+ // Accept both shapes: {role} (array form) and {type} (gemini-envelope form,
201
+ // where type 'gemini' maps to assistant for cross-adapter parity).
202
+ let role = m.role;
203
+ if (!role && m.type) role = (m.type === 'user') ? 'user' : 'assistant';
204
+ if (role !== 'user' && role !== 'assistant') continue;
205
+ const content = m.content;
206
+ let text = '';
207
+ if (typeof content === 'string') text = content;
208
+ else if (Array.isArray(content)) {
209
+ text = content
210
+ .filter((c) => c && typeof c.text === 'string')
211
+ .map((c) => c.text)
212
+ .join(' ');
213
+ }
214
+ if (text) out.push({ role, content: text.slice(0, 400) });
215
+ }
216
+ return out;
217
+ }
218
+
219
+ // ──────────────────────────────────────────────────────────────────────────
220
+ // parseTranscript — dual-mode. Structured envelope (this adapter's own
221
+ // tempfile, or a {role,content} array) is parsed directly; otherwise the input
222
+ // is raw PTY/TUI capture and gets the ANSI-strip + de-chrome + segment path.
223
+ // Returns [] on empty/garbage (parity-test fail-soft contract).
224
+ // ──────────────────────────────────────────────────────────────────────────
225
+
226
+ function parseTranscript(raw) {
227
+ if (typeof raw !== 'string' || raw.length === 0) return [];
228
+ const trimmed = raw.trimStart();
229
+ if (trimmed[0] === '{' || trimmed[0] === '[') {
230
+ const structured = _parseStructured(raw);
231
+ if (structured.length > 0) return structured;
232
+ }
233
+ return _cleanAndSegment(raw);
234
+ }
235
+
236
+ // ──────────────────────────────────────────────────────────────────────────
237
+ // resolveTranscriptPath — Sprint 70 T1. There is no on-disk transcript to
238
+ // resolve; instead we materialize the in-flight PTY capture buffer
239
+ // (`session._stdoutCapture`, populated by spawnTerminalSession's tee) into a
240
+ // tempfile the bundled hook can read. Mirrors grok.js's tempfile-envelope
241
+ // approach. Returns null when the panel produced no output (buffer empty /
242
+ // absent / parses to zero messages) so onPanelClose + the periodic-capture
243
+ // timer no-op cleanly.
244
+ //
245
+ // Side effect (matching grok.js): writes a tempfile. Called by BOTH onPanelClose
246
+ // (once, at exit) and onPanelPeriodicCapture (every interval) — each call
247
+ // re-materializes the current buffer, so the periodic timer's size-delta
248
+ // throttle sees the transcript grow correctly.
249
+ // ──────────────────────────────────────────────────────────────────────────
250
+
251
+ async function resolveTranscriptPath(session) {
252
+ const fs = require('fs');
253
+ const path = require('path');
254
+ const os = require('os');
255
+ if (!session || !session.meta) return null;
256
+ const cap = session._stdoutCapture;
257
+ if (!cap || !Array.isArray(cap.chunks) || cap.chunks.length === 0) return null;
258
+
259
+ const raw = cap.chunks.join('');
260
+ if (!raw) return null;
261
+
262
+ const messages = parseTranscript(raw);
263
+ if (messages.length === 0) return null;
264
+
265
+ // Gemini-shaped envelope: the bundled hook's parseAutoDetect/parseGeminiJson
266
+ // consume `{messages:[{type,content}]}` as-is (type 'user'|'assistant'), so no
267
+ // dedicated antigravity parser is required in the hook.
268
+ const envelope = {
269
+ messages: messages.map((m) => ({ type: m.role, content: m.content })),
270
+ };
271
+
272
+ const safeId = String(session.id || `unknown-${session.pid || ''}`)
273
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
274
+ const tmpfile = path.join(os.tmpdir(), `termdeck-agy-${safeId}.json`);
275
+ try {
276
+ fs.writeFileSync(tmpfile, JSON.stringify(envelope), 'utf8');
277
+ } catch (_) {
278
+ return null; // fail-soft — a tmpfile write failure must not block teardown
279
+ }
280
+ return tmpfile;
281
+ }
282
+
283
+ // ──────────────────────────────────────────────────────────────────────────
284
+ // bootPromptTemplate — Antigravity reads `AGENTS.md` (its project-prompt
285
+ // convention, shared with Codex/Grok via scripts/sync-agent-instructions.js).
286
+ // Same memory_recall + read-instructional-file + read-sprint-docs scaffold as
287
+ // the other adapters. Contract-complete placeholder; Sprint-46-style per-agent
288
+ // refinement is a follow-up.
289
+ // ──────────────────────────────────────────────────────────────────────────
290
+
291
+ function bootPromptTemplate(lane = {}, sprint = {}) {
292
+ const tn = lane.id || 'T?';
293
+ const sprintNum = sprint.number || '?';
294
+ const sprintName = sprint.name || 'unnamed';
295
+ const project = (lane.project || sprint.project || 'termdeck');
296
+ const briefing = lane.briefingPath || `docs/sprint-${sprintNum}-${sprintName}/${tn}-<lane>.md`;
297
+ return [
298
+ `You are ${tn} in Sprint ${sprintNum} (${sprintName}). Boot sequence:`,
299
+ `1. memory_recall(project="${project}", query="<topic>")`,
300
+ `2. memory_recall(query="<broader topic>")`,
301
+ `3. Read ~/.claude/CLAUDE.md and ./AGENTS.md`,
302
+ `4. Read docs/sprint-${sprintNum}-${sprintName}/PLANNING.md`,
303
+ `5. Read docs/sprint-${sprintNum}-${sprintName}/STATUS.md`,
304
+ `6. Read ${briefing}`,
305
+ '',
306
+ 'Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in STATUS.md.',
307
+ "Don't bump versions, don't touch CHANGELOG, don't commit.",
308
+ ].join('\n');
309
+ }
310
+
311
+ // ──────────────────────────────────────────────────────────────────────────
312
+ // mcpConfig — UNVERIFIED at lane time. The brief specifies the path
313
+ // `~/.gemini/antigravity-cli/mcp_config.json`, which does NOT exist on disk yet,
314
+ // and agy's `settings.json` carries no `mcpServers` key — so agy's actual
315
+ // MCP-registry read path could not be confirmed against the binary. Modeled on
316
+ // the Gemini-family record shape (`mcpServers.NAME = {command,args,env}`, the
317
+ // same schema gemini.js uses) since Antigravity is a Gemini-family CLI. The
318
+ // shared mcp-autowire helper would CREATE this file on panel spawn. This is a
319
+ // non-load-bearing nicety (auto-wiring Mnestra into agy panels); if a future
320
+ // probe shows agy reads MCP from settings.json or another path/shape, correct
321
+ // here. Env-key omission discipline matches gemini.js (concrete-or-omit; agy,
322
+ // like Claude/Gemini, does not shell-expand `${VAR}` in MCP env).
323
+ // ──────────────────────────────────────────────────────────────────────────
324
+
325
+ const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
326
+
327
+ function buildMnestraBlock({ secrets } = {}) {
328
+ const env = {};
329
+ for (const key of MNESTRA_ENV_KEYS) {
330
+ const value = secrets && secrets[key];
331
+ if (typeof value === 'string' && value.length > 0 && !/^\$\{[^}]*\}$/.test(value)) {
332
+ env[key] = value;
333
+ }
334
+ }
335
+ return { mnestra: { command: 'mnestra', args: [], env } };
336
+ }
337
+
338
+ const antigravityAdapter = {
339
+ name: 'antigravity',
340
+ sessionType: 'antigravity',
341
+ // Explicit canonical source_agent (belt-and-suspenders vs `name`; consumed by
342
+ // the Sprint-70-T3 `adapter.sourceAgent || adapter.name` server change).
343
+ sourceAgent: 'antigravity',
344
+ displayName: 'Antigravity',
345
+ // Match the `agy` binary. `agy` shares no substring with claude/codex/gemini/
346
+ // grok, so this is mutually exclusive across the registry (parity test 108).
347
+ matches: (cmd) => typeof cmd === 'string' && /(?:^|\s|\/)agy(?:\b|$)/i.test(cmd),
348
+ spawn: {
349
+ binary: 'agy',
350
+ defaultArgs: [],
351
+ // OAuth-personal auth (agy stays on OAuth while gemini moved to API-key —
352
+ // the auth-segregation the Sprint 70 migration is built around). No env
353
+ // overlay needed; the PTY inherits the user's environment.
354
+ env: {},
355
+ // Direct spawn (no `zsh -c` wrapper) — same carve-out the other four
356
+ // adapters use. Required by adapter-spawn-shell-wrap.test.js:175.
357
+ shellWrap: false,
358
+ },
359
+ // Sprint 70 T1 — opt-in in-flight stdout capture. Absent on every other
360
+ // adapter, so this is the ONLY adapter spawnTerminalSession tees. `mode`
361
+ // selects the capture strategy; `maxBytes` tail-caps the in-memory buffer
362
+ // (TUI redraws inflate raw bytes far past the de-chromed content, so cap
363
+ // generously and keep the tail — the most recent conversation); `unbuffer`
364
+ // opts into the best-effort `stdbuf` buffering-defense (residual; see header).
365
+ capture: {
366
+ mode: 'stdout',
367
+ maxBytes: 4 * 1024 * 1024,
368
+ unbuffer: true,
369
+ },
370
+ patterns: {
371
+ prompt: PROMPT,
372
+ thinking: THINKING,
373
+ // editing / tool / error intentionally omitted — the TUI screen-scrape is
374
+ // too noisy for reliable line-anchored edit/tool/error detection without a
375
+ // calibrated real-turn capture. session.js falls back to the generic
376
+ // PATTERNS.error, matching gemini's conservative posture.
377
+ },
378
+ patternNames: {},
379
+ statusFor,
380
+ parseTranscript,
381
+ resolveTranscriptPath,
382
+ bootPromptTemplate,
383
+ costBand: 'subscription',
384
+ // Antigravity's input handling hasn't been pasted-against empirically; default
385
+ // true (bracketed-paste fast path), flip to false if a lane-time test shows
386
+ // the TUI input box eats the paste markers.
387
+ acceptsPaste: true,
388
+ mcpConfig: {
389
+ path: '~/.gemini/antigravity-cli/mcp_config.json',
390
+ format: 'json',
391
+ mcpServersKey: 'mcpServers',
392
+ mnestraBlock: buildMnestraBlock,
393
+ },
394
+ };
395
+
396
+ module.exports = antigravityAdapter;