@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.
@@ -16,6 +16,9 @@ const claude = require('./claude');
16
16
  const codex = require('./codex');
17
17
  const gemini = require('./gemini');
18
18
  const grok = require('./grok');
19
+ // Sprint 70 T1 — Antigravity CLI (`agy`). Registered under its canonical
20
+ // adapter name `antigravity` (= source_agent); the binary it matches is `agy`.
21
+ const antigravity = require('./agy');
19
22
 
20
23
  // Keyed by adapter name (NOT session.meta.type — adapters expose their own
21
24
  // `sessionType` field for that mapping). Order is iteration order for the
@@ -25,6 +28,10 @@ const AGENT_ADAPTERS = {
25
28
  codex,
26
29
  gemini,
27
30
  grok,
31
+ // Listed last: its idle `> ` prompt overlaps claude's, and claude (first)
32
+ // claims that string in the detect loop. agy panels are normally resolved by
33
+ // exact-binary direct-spawn, not output sniffing, so order is not load-bearing.
34
+ antigravity,
28
35
  };
29
36
 
30
37
  // Convenience accessor — returns the adapter whose `sessionType` matches the
@@ -290,7 +290,13 @@ async function onPanelClose(session) {
290
290
  session_id: session.id,
291
291
  sessionType: adapter.sessionType,
292
292
  // Sprint 50 — T2 consumes this via the new memory_items.source_agent column.
293
- source_agent: adapter.name,
293
+ // Sprint 70 T3 — prefer an explicit `adapter.sourceAgent` provenance tag
294
+ // when an adapter declares one (decouples the provenance string from the
295
+ // registry/binary-match `name`); existing adapters omit it and fall back
296
+ // to `name` (behavior unchanged). The antigravity (`agy`) adapter sets
297
+ // sourceAgent:'antigravity'; the session-end hook's normalizeSourceAgent
298
+ // also aliases the binary name `agy` → `antigravity` as a safety net.
299
+ source_agent: adapter.sourceAgent || adapter.name,
294
300
  };
295
301
 
296
302
  _spawnSessionEndHookImpl(hookPath, payload, {
@@ -355,7 +361,10 @@ async function onPanelPeriodicCapture(session) {
355
361
  cwd: session.meta.cwd,
356
362
  session_id: session.id,
357
363
  sessionType: adapter.sessionType,
358
- source_agent: adapter.name,
364
+ // Sprint 70 T3 — same provenance contract as onPanelClose: an explicit
365
+ // adapter.sourceAgent wins, else fall back to adapter.name (unchanged for
366
+ // existing adapters). agy panels' periodic snapshots tag 'antigravity'.
367
+ source_agent: adapter.sourceAgent || adapter.name,
359
368
  // Mode discriminator the hook reads in resolveFiringContext —
360
369
  // distinguishes "TermDeck server periodic capture" from "Claude Code
361
370
  // PreCompact harness fire."
@@ -388,6 +397,50 @@ function _resolvePeriodicCaptureIntervalMs() {
388
397
  return n;
389
398
  }
390
399
 
400
+ // Sprint 70 T1 — best-effort line-buffering wrap for stdout-capture adapters.
401
+ //
402
+ // The LOAD-BEARING capture mechanism is the PTY tee in spawnTerminalSession;
403
+ // this wrap is a RESIDUAL buffering-defense, valuable only for line-buffered
404
+ // C-stdio CLIs and timelier mid-session periodic checkpoints. It is inert for a
405
+ // compiled binary like `agy` (libstdbuf only affects glibc stdio) and a no-op
406
+ // on hosts without a stdbuf-family tool (stock macOS) — the tee captures
407
+ // everything regardless. We PREFER `stdbuf`/`gstdbuf` (GNU coreutils) because
408
+ // it exec()s the target IN PLACE: same controlling TTY, pid preserved, exit
409
+ // code propagated. We deliberately do NOT use `unbuffer` (expect) — it
410
+ // allocates its own pty, producing a double-pty that strips the interactive-TTY
411
+ // context agent CLIs need (Sprint 64 T2 carve-out 2.4 rationale).
412
+ let _stdbufToolCache; // undefined = unprobed, string = tool name, null = none on PATH
413
+ function _defaultLookStdbuf() {
414
+ if (_stdbufToolCache !== undefined) return _stdbufToolCache;
415
+ const { spawnSync } = require('child_process');
416
+ _stdbufToolCache = null;
417
+ for (const name of ['stdbuf', 'gstdbuf']) {
418
+ try {
419
+ const r = spawnSync('/bin/sh', ['-c', `command -v ${name}`], { encoding: 'utf8' });
420
+ if (r && r.status === 0 && typeof r.stdout === 'string' && r.stdout.trim()) {
421
+ _stdbufToolCache = name;
422
+ break;
423
+ }
424
+ } catch (_) { /* try next candidate */ }
425
+ }
426
+ return _stdbufToolCache;
427
+ }
428
+
429
+ // Returns the (possibly rewritten) { binary, args } to hand pty.spawn. No-op
430
+ // unless the adapter declares `capture.mode==='stdout'` AND `capture.unbuffer`.
431
+ // `lookPath` is dependency-injected so tests stay hermetic (no real stdbuf
432
+ // dependence). Resets the memo via `_resetStdbufToolCacheForTesting`.
433
+ function _resolveStdoutCaptureSpawn(binary, args, capture, lookPath = _defaultLookStdbuf) {
434
+ if (!capture || capture.mode !== 'stdout' || !capture.unbuffer) {
435
+ return { binary, args };
436
+ }
437
+ let tool = null;
438
+ try { tool = lookPath(); } catch (_) { tool = null; }
439
+ if (!tool) return { binary, args }; // graceful fallback — bare direct-spawn
440
+ return { binary: tool, args: ['-oL', '-eL', binary, ...args] };
441
+ }
442
+ function _resetStdbufToolCacheForTesting() { _stdbufToolCache = undefined; }
443
+
391
444
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
392
445
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
393
446
  // them here and pass them into the helper. If a module is missing (e.g.
@@ -1361,6 +1414,18 @@ function createServer(config) {
1361
1414
  args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
1362
1415
  }
1363
1416
 
1417
+ // Sprint 70 T1 — stdout-capture adapters (agy) may opt into a best-effort
1418
+ // line-buffering wrap of the direct-spawn. No-op for every other adapter
1419
+ // (none declare `capture`) and for the shell-wrap path. Gated on
1420
+ // directSpawnAdapter because a capture declaration only rides the
1421
+ // exact-binary direct-spawn path. Falls back to the bare binary when no
1422
+ // stdbuf-family tool is on PATH; the PTY tee below captures regardless.
1423
+ if (directSpawnAdapter && directSpawnAdapter.capture) {
1424
+ const wrapped = _resolveStdoutCaptureSpawn(spawnShell, args, directSpawnAdapter.capture);
1425
+ spawnShell = wrapped.binary;
1426
+ args = wrapped.args;
1427
+ }
1428
+
1364
1429
  try {
1365
1430
  // Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
1366
1431
  // the bundled session-end memory hook (`memory-session-end.js`) sees
@@ -1472,10 +1537,43 @@ function createServer(config) {
1472
1537
  }
1473
1538
  } catch (_periodicErr) { /* fail-soft */ }
1474
1539
 
1540
+ // Sprint 70 T1 — initialize the in-flight stdout capture buffer for
1541
+ // adapters that opt in (agy). The tee in term.onData below appends to
1542
+ // it; resolveTranscriptPath materializes it into a tempfile envelope at
1543
+ // panel close + on the periodic-capture tick. Gated on the direct-spawn
1544
+ // adapter's declaration so non-capture panels carry no buffer (zero
1545
+ // overhead; their behavior is byte-for-byte unchanged).
1546
+ if (directSpawnAdapter && directSpawnAdapter.capture
1547
+ && directSpawnAdapter.capture.mode === 'stdout') {
1548
+ const declaredMax = directSpawnAdapter.capture.maxBytes;
1549
+ const maxBytes = (typeof declaredMax === 'number' && declaredMax > 0)
1550
+ ? declaredMax
1551
+ : 4 * 1024 * 1024;
1552
+ session._stdoutCapture = { chunks: [], bytes: 0, maxBytes };
1553
+ }
1554
+
1475
1555
  // PTY output → analyze + broadcast to WebSocket + transcript archive
1476
1556
  term.onData((data) => {
1477
1557
  session.analyzeOutput(data);
1478
1558
 
1559
+ // Sprint 70 T1 — tee PTY output into the in-flight capture buffer for
1560
+ // stdout-capture adapters (agy). Tail-capped: when the buffer exceeds
1561
+ // maxBytes we drop whole chunks from the FRONT, keeping the most
1562
+ // recent conversation (TUI redraws inflate raw bytes far past the
1563
+ // de-chromed content). Best-effort — a capture failure must never
1564
+ // disrupt the load-bearing PTY data path below.
1565
+ const cap = session._stdoutCapture;
1566
+ if (cap) {
1567
+ try {
1568
+ cap.chunks.push(data);
1569
+ cap.bytes += Buffer.byteLength(data, 'utf8');
1570
+ while (cap.bytes > cap.maxBytes && cap.chunks.length > 1) {
1571
+ const dropped = cap.chunks.shift();
1572
+ cap.bytes -= Buffer.byteLength(dropped, 'utf8');
1573
+ }
1574
+ } catch (_capErr) { /* capture is best-effort */ }
1575
+ }
1576
+
1479
1577
  // Send to connected WebSocket
1480
1578
  if (session.ws && session.ws.readyState === 1) {
1481
1579
  session.ws.send(JSON.stringify({ type: 'output', data }));
@@ -3090,4 +3188,7 @@ module.exports = {
3090
3188
  onPanelPeriodicCapture,
3091
3189
  _setSpawnPeriodicCaptureHookImplForTesting,
3092
3190
  _resolvePeriodicCaptureIntervalMs,
3191
+ // Sprint 70 T1 — stdout-capture spawn-wrap resolver (best-effort stdbuf).
3192
+ _resolveStdoutCaptureSpawn,
3193
+ _resetStdbufToolCacheForTesting,
3093
3194
  };
@@ -1,6 +1,6 @@
1
1
  // Sprint 51.5 T1 — schema-introspection audit-upgrade.
2
2
  //
3
- // Brad's 2026-05-02 jizzard-brain report (INSTALLER-PITFALLS.md ledger #13)
3
+ // Brad's 2026-05-02 peer install report (INSTALLER-PITFALLS.md ledger #13)
4
4
  // surfaced Class A — schema drift. The user upgraded npm packages but the
5
5
  // database stayed frozen at first-kickstart: graph-inference Edge Function
6
6
  // never deployed, vault key never created, Mnestra migrations 009-015 + TD
@@ -124,7 +124,7 @@ const PROBES = Object.freeze([
124
124
  // the rich rag-system column set to memory_sessions; canonical engram
125
125
  // mig 001 only ships (id, project, summary, metadata, created_at).
126
126
  // Probe for memory_sessions.session_id (the most distinctive of the
127
- // mig-017 columns) and apply mig 017 if absent. Idempotent on petvetbid
127
+ // mig-017 columns) and apply mig 017 if absent. Idempotent on the daily-driver project
128
128
  // where the columns are already present from hand-applied DDL.
129
129
  name: 'memory_sessions.session_id',
130
130
  kind: 'mnestra',
@@ -180,7 +180,7 @@ const PROBES = Object.freeze([
180
180
  // Sprint 51.6 T3 — Brad's Bug D: function-existence probes (cron schedule
181
181
  // checks for jobname presence) are not enough. The deployed Edge Function
182
182
  // SOURCE may be stale even when the cron job and function both exist.
183
- // jizzard-brain on 2026-05-03: deployed rumen-tick was missing the
183
+ // peer install on 2026-05-03: deployed rumen-tick was missing the
184
184
  // SUPABASE_DB_URL fallback that Sprint 51.5 T1 added; cron probe said
185
185
  // "present", source was old. The marker check below detects that drift.
186
186
  //
@@ -135,7 +135,7 @@ async function fetchCandidatePairs(
135
135
  // m2 is the outer m1 (which IS recent). So filtering only m1 by `since`
136
136
  // is sufficient and saves ~99% of work in steady state.
137
137
  //
138
- // EXPLAIN ANALYZE on petvetbid corpus (5,822 active rows, 2026-04-28):
138
+ // EXPLAIN ANALYZE on the daily-driver project corpus (5,822 active rows, 2026-04-28):
139
139
  // 13.5s cold start (since=NULL), HNSW correctly engaged, 718 raw
140
140
  // matches → 359 unique pairs at threshold 0.85.
141
141
  const result = await sql.unsafe(
@@ -39,7 +39,7 @@
39
39
  * 9. (Sprint 51.6 T3) POSTs ONE row to Supabase /rest/v1/memory_sessions with
40
40
  * Prefer: resolution=merge-duplicates so SessionEnd-fires-twice resolves
41
41
  * to a single row. Requires Mnestra migration 017 on canonical installs;
42
- * petvetbid already has the rich schema from rag-system bootstrap.
42
+ * the daily-driver project already has the rich schema from rag-system bootstrap.
43
43
  * 10. Logs every step to ~/.claude/hooks/memory-hook.log.
44
44
  *
45
45
  * Version stamp (Sprint 51.6 T3 — hook upgrade gap fix):
@@ -61,7 +61,7 @@
61
61
  * to bundled-v2 always passes the `installed >= bundled` short-circuit
62
62
  * at init-mnestra.js:550 and reaches the refresh path.
63
63
  *
64
- * @termdeck/stack-installer-hook v2
64
+ * @termdeck/stack-installer-hook v3
65
65
  *
66
66
  * Required env vars (validated at entry, after the secrets.env fallback):
67
67
  * - SUPABASE_URL e.g. https://<project-ref>.supabase.co
@@ -305,35 +305,43 @@ function parseCodexJsonl(raw) {
305
305
  }
306
306
 
307
307
  function parseGeminiJson(raw) {
308
- // Gemini CLI persists each session as a single JSON object (NOT JSONL):
309
- // { sessionId, projectHash, startTime, lastUpdated, kind,
310
- // messages: [{ id, timestamp, type: 'user'|'gemini', content }] }
311
- // user content: [{ text }]; gemini content: string. Map type='gemini' →
312
- // role='assistant' to match the rest of the dispatch shape.
308
+ // Sprint 70 T2/T3 cross-lane: handles BOTH transcript shapes
309
+ // (A) legacy single JSON object {..., messages:[{type,content}]} (.json) +
310
+ // (B) modern JSONL — header line, `{ "$set": ... }` deltas, and message lines
311
+ // {id,timestamp,type:'user'|'gemini'|'info',content} (.jsonl, ships today).
312
+ // Pre-Sprint-70 this did one whole-blob JSON.parse threw on every modern
313
+ // .jsonl file → returned [] → captured nothing. user content = array of {text};
314
+ // gemini content = string; gemini → assistant.
315
+ // Keep in sync with packages/server/src/agent-adapters/gemini.js::parseTranscript.
313
316
  if (typeof raw !== 'string' || raw.length === 0) return [];
314
- let obj;
315
- try { obj = JSON.parse(raw); } catch (_) { return []; }
316
- if (!obj || !Array.isArray(obj.messages)) return [];
317
- const messages = [];
318
- for (const msg of obj.messages) {
319
- if (!msg || typeof msg !== 'object') continue;
317
+ const out = [];
318
+ const pushMsg = (msg) => {
319
+ if (!msg || typeof msg !== 'object') return;
320
320
  let role;
321
321
  if (msg.type === 'user') role = 'user';
322
322
  else if (msg.type === 'gemini' || msg.type === 'assistant') role = 'assistant';
323
- else continue;
323
+ else return;
324
324
  const content = msg.content;
325
325
  let text = '';
326
- if (typeof content === 'string') {
327
- text = content;
328
- } else if (Array.isArray(content)) {
329
- text = content
330
- .filter((c) => c && typeof c.text === 'string')
331
- .map((c) => c.text)
332
- .join(' ');
326
+ if (typeof content === 'string') text = content;
327
+ else if (Array.isArray(content)) {
328
+ text = content.filter((c) => c && typeof c.text === 'string').map((c) => c.text).join(' ');
333
329
  }
334
- if (text) messages.push({ role, content: text.slice(0, 400) });
330
+ if (text) out.push({ role, content: text.slice(0, 400) });
331
+ };
332
+ const collect = (node) => {
333
+ if (!node || typeof node !== 'object') return;
334
+ if (Array.isArray(node.messages)) node.messages.forEach(pushMsg);
335
+ else pushMsg(node);
336
+ };
337
+ try { collect(JSON.parse(raw)); if (out.length) return out; } catch (_) { /* JSONL */ }
338
+ for (const line of raw.split(/\r?\n/)) {
339
+ const t = line.trim();
340
+ if (!t) continue;
341
+ let node; try { node = JSON.parse(t); } catch (_) { continue; }
342
+ collect(node);
335
343
  }
336
- return messages;
344
+ return out;
337
345
  }
338
346
 
339
347
  // Sprint 50 T1 — Grok parser. Mirrors packages/server/src/agent-adapters/grok.js
@@ -469,7 +477,7 @@ function selectTranscriptParser(sessionType) {
469
477
  // per-message timestamps. The legacy rag-system writer
470
478
  // (~/Documents/Graciella/rag-system/src/scripts/process-session.ts) populated
471
479
  // those fields by parsing the transcript JSONL passed to it on stdin, and
472
- // petvetbid's 289 baseline rows carried the rich shape from that writer.
480
+ // the daily-driver project's 289 baseline rows carried the rich shape from that writer.
473
481
  // v2 closes the gap in pure Node so the bundled hook reaches parity without
474
482
  // the rag-system dependency (Class E hidden-dependency rule).
475
483
  //
@@ -637,18 +645,31 @@ async function embedText(text, openaiKey) {
637
645
  // tag (memory_items.source_agent). Defaults to 'claude' for backwards
638
646
  // compat with Claude Code's existing SessionEnd payload, which doesn't
639
647
  // supply the field; TermDeck server's per-adapter onPanelClose
640
- // interceptor (Sprint 50 T1) sets it explicitly to 'codex'/'gemini'/'grok'
641
- // for non-Claude panels. The set is open-ended on the server side; this
642
- // constant gates only the spelling-mistake/empty-string case.
648
+ // interceptor (Sprint 50 T1) sets it explicitly to 'codex'/'gemini'/'grok'/
649
+ // 'antigravity' for non-Claude panels. The set is open-ended on the server
650
+ // side; this constant gates only the spelling-mistake/empty-string case.
651
+ //
652
+ // Sprint 70 T3: Antigravity (`agy`) is a first-class source_agent. The CLI
653
+ // binary is `agy` but the canonical provenance tag is `antigravity`, so the
654
+ // alias map below folds `agy` → `antigravity` before the allowlist check —
655
+ // an agy panel's memories must not be mis-tagged `claude`.
643
656
  const ALLOWED_SOURCE_AGENTS = new Set([
644
- 'claude', 'codex', 'gemini', 'grok', 'orchestrator',
657
+ 'claude', 'codex', 'gemini', 'grok', 'orchestrator', 'antigravity',
645
658
  ]);
646
659
 
660
+ // Alias → canonical source_agent. Keeps the binary name (`agy`) and any older
661
+ // callers from being dropped to 'claude' by the allowlist gate. Applied (after
662
+ // lowercasing) before the ALLOWED_SOURCE_AGENTS membership test.
663
+ const SOURCE_AGENT_ALIASES = {
664
+ agy: 'antigravity',
665
+ };
666
+
647
667
  function normalizeSourceAgent(raw) {
648
668
  if (typeof raw !== 'string') return 'claude';
649
669
  const v = raw.trim().toLowerCase();
650
670
  if (!v) return 'claude';
651
- return ALLOWED_SOURCE_AGENTS.has(v) ? v : 'claude';
671
+ const canonical = SOURCE_AGENT_ALIASES[v] || v;
672
+ return ALLOWED_SOURCE_AGENTS.has(canonical) ? canonical : 'claude';
652
673
  }
653
674
 
654
675
  async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId, sourceAgent }) {
@@ -694,10 +715,10 @@ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, pr
694
715
  // closes it.
695
716
  //
696
717
  // Schema target: Mnestra migration 017 brings canonical engram in line with
697
- // petvetbid's rag-system flavor (session_id, summary_embedding, started_at,
718
+ // the daily-driver project's rag-system flavor (session_id, summary_embedding, started_at,
698
719
  // ended_at, duration_minutes, messages_count, transcript_path, etc). The
699
720
  // bundled hook writes the rich shape on every install — fresh-canonical
700
- // (post-mig-017) and petvetbid alike.
721
+ // (post-mig-017) and the daily-driver project alike.
701
722
  //
702
723
  // Idempotency: Prefer: resolution=merge-duplicates relies on the
703
724
  // memory_sessions_session_id_key unique constraint. Mig 017 adds it where
@@ -804,7 +825,27 @@ async function processStdinPayload(input) {
804
825
  try { stat = statSync(transcriptPath); }
805
826
  catch (e) { log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`); return; }
806
827
 
807
- if (stat.size < MIN_TRANSCRIPT_BYTES) {
828
+ // Sprint 70 T1 (A1 RED fix — ORCH 2026-06-07 19:21 ET). The raw-byte floor is
829
+ // calibrated for verbose on-disk JSONL (claude/codex/gemini/grok session files
830
+ // run 10s of KB even when short). Antigravity has no on-disk transcript — its
831
+ // capture is a synthesized COMPACT stdout-tee envelope (cleaned, de-chromed
832
+ // content only), so a genuinely-substantive short agy session is legitimately
833
+ // <5KB and the byte floor would wrongly drop it (false zero-row). Exempt
834
+ // sessionType==='antigravity' from the byte floor and gate on parsed CONTENT
835
+ // instead — require >= 1 assistant turn so an empty / no-model-output capture
836
+ // still no-ops. Do NOT lower the global floor; it correctly filters trivial
837
+ // verbose sessions for every other agent.
838
+ if (sessionType === 'antigravity') {
839
+ let agyRaw = '';
840
+ try { agyRaw = readFileSync(transcriptPath, 'utf8'); }
841
+ catch (e) { log(`cannot-read-transcript: ${transcriptPath} — ${e.message}`); return; }
842
+ const agyTurns = selectTranscriptParser(sessionType).parser(agyRaw);
843
+ const assistantTurns = agyTurns.filter((m) => m && m.role === 'assistant').length;
844
+ if (assistantTurns < 1) {
845
+ debug(`antigravity-no-assistant-turn: ${agyTurns.length} parsed, 0 assistant — skipping`);
846
+ return;
847
+ }
848
+ } else if (stat.size < MIN_TRANSCRIPT_BYTES) {
808
849
  debug(`small-transcript: ${stat.size} bytes < ${MIN_TRANSCRIPT_BYTES}, skipping`);
809
850
  return;
810
851
  }