@jhizzard/termdeck 0.18.0 → 1.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.18.0",
3
+ "version": "1.0.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -53,6 +53,20 @@
53
53
  }
54
54
  } catch (_) { /* keep bootstrap fallback */ }
55
55
 
56
+ // Sprint 50 T3 — adapter-driven launcher buttons. Render one button
57
+ // per registered agent in the topbar quick-launch and the empty-state
58
+ // tile group. Replaces the pre-Sprint-50 hardcoded `claude` button
59
+ // that left Codex/Gemini/Grok with no one-click launcher (forcing
60
+ // free-form `codex`/`gemini`/`grok` typing in the prompt bar — a v1.0.0
61
+ // gate-blocker UX gap surfaced during the Sprint 49 mixed-agent
62
+ // dogfood). Static `shell` + `python` entries stay (non-adapter
63
+ // built-ins). HTML fallback shapes are preserved if rendering fails.
64
+ try {
65
+ renderQuickLaunchers();
66
+ } catch (err) {
67
+ console.warn('[client] launcher render failed, keeping HTML fallback:', err);
68
+ }
69
+
56
70
  // Populate project dropdown
57
71
  const sel = document.getElementById('promptProject');
58
72
  for (const name of Object.keys(state.config.projects || {})) {
@@ -1586,6 +1600,98 @@
1586
1600
  launchTerminal();
1587
1601
  }
1588
1602
 
1603
+ // ===== Adapter-driven launcher buttons (Sprint 50 T3) =====
1604
+ //
1605
+ // Built-in non-adapter entries that flank the adapter list. `shell` is
1606
+ // the always-on fallback panel; `python` is the HTTP-server convenience
1607
+ // launcher that long predates the multi-agent registry.
1608
+ const BUILTIN_LAUNCHERS = {
1609
+ pre: [
1610
+ { command: 'zsh', label: 'shell', title: 'Open a zsh shell' },
1611
+ ],
1612
+ post: [
1613
+ {
1614
+ command: 'python3 -m http.server 8080',
1615
+ label: 'python',
1616
+ title: 'Open a Python HTTP server on :8080',
1617
+ },
1618
+ ],
1619
+ };
1620
+
1621
+ // One launcher button. Reuses the same `quickLaunch(cmd)` path the
1622
+ // hardcoded HTML buttons used so command resolution (LauncherResolver
1623
+ // + /api/sessions) is unchanged.
1624
+ function makeLauncherButton(cmd, label, title, className) {
1625
+ const btn = document.createElement('button');
1626
+ btn.type = 'button';
1627
+ btn.className = className;
1628
+ btn.textContent = label;
1629
+ if (title) btn.title = title;
1630
+ btn.dataset.command = cmd;
1631
+ btn.addEventListener('click', () => quickLaunch(cmd));
1632
+ return btn;
1633
+ }
1634
+
1635
+ function adapterLauncherEntries() {
1636
+ const adapters = Array.isArray(state.agentAdapters) ? state.agentAdapters : [];
1637
+ return adapters.map((a) => ({
1638
+ command: a.binary || a.name,
1639
+ label: (a.displayName || a.name || a.binary || '').toLowerCase(),
1640
+ // Title text gets the canonical displayName so the tooltip preserves
1641
+ // the proper-cased "Claude Code" / "Codex CLI" form even when the
1642
+ // button face renders lowercase to match TermDeck's chrome style.
1643
+ title: `Open ${a.displayName || a.name || a.binary}`,
1644
+ }));
1645
+ }
1646
+
1647
+ function renderQuickLaunchers() {
1648
+ const adapters = adapterLauncherEntries();
1649
+ const ordered = [
1650
+ ...BUILTIN_LAUNCHERS.pre,
1651
+ ...adapters,
1652
+ ...BUILTIN_LAUNCHERS.post,
1653
+ ];
1654
+
1655
+ // Topbar — compact buttons.
1656
+ const topbar = document.getElementById('topbarQuickLaunch');
1657
+ if (topbar) {
1658
+ topbar.replaceChildren();
1659
+ for (const entry of ordered) {
1660
+ topbar.appendChild(
1661
+ makeLauncherButton(entry.command, entry.label, entry.title, 'topbar-ql-btn'),
1662
+ );
1663
+ }
1664
+ }
1665
+
1666
+ // Empty state — taller tiles with the raw command rendered as a
1667
+ // secondary line. Mirrors the pre-Sprint-50 markup so existing CSS
1668
+ // classes (`quick-launch-btn`, `ql-cmd`, `ql-desc`) keep their styling.
1669
+ const emptyGroup = document.querySelector('#emptyState .quick-launch-group');
1670
+ if (emptyGroup) {
1671
+ emptyGroup.replaceChildren();
1672
+ for (const entry of ordered) {
1673
+ const btn = document.createElement('button');
1674
+ btn.type = 'button';
1675
+ btn.className = 'quick-launch-btn';
1676
+ btn.title = entry.title;
1677
+ btn.dataset.command = entry.command;
1678
+
1679
+ const cmd = document.createElement('span');
1680
+ cmd.className = 'ql-cmd';
1681
+ cmd.textContent = entry.command;
1682
+
1683
+ const desc = document.createElement('span');
1684
+ desc.className = 'ql-desc';
1685
+ desc.textContent = entry.title;
1686
+
1687
+ btn.appendChild(cmd);
1688
+ btn.appendChild(desc);
1689
+ btn.addEventListener('click', () => quickLaunch(entry.command));
1690
+ emptyGroup.appendChild(btn);
1691
+ }
1692
+ }
1693
+ }
1694
+
1589
1695
  // ===== Add Project modal =====
1590
1696
  function rebuildProjectDropdown(selectName) {
1591
1697
  const sel = document.getElementById('promptProject');
@@ -2571,10 +2677,23 @@
2571
2677
  }
2572
2678
 
2573
2679
  function getTypeLabel(type) {
2680
+ // Sprint 50 T3 — adapter-driven panel header labels. Consult
2681
+ // state.agentAdapters first so a freshly-launched Codex/Gemini/Grok
2682
+ // panel reads its agent's displayName (rather than the raw
2683
+ // sessionType string or — worse — falling through to "Shell" when
2684
+ // the type label map didn't have an entry). Adding a new agent now
2685
+ // requires only an adapter file with `displayName`; no client-side
2686
+ // edit. Built-in non-adapter types (shell / python-server / etc.)
2687
+ // keep their static labels.
2688
+ const adapters = Array.isArray(state.agentAdapters) ? state.agentAdapters : [];
2689
+ const adapter = adapters.find((a) => a && a.sessionType === type);
2690
+ if (adapter && adapter.displayName) return adapter.displayName;
2574
2691
  const labels = {
2575
2692
  'shell': 'Shell',
2576
2693
  'claude-code': 'Claude Code',
2694
+ 'codex': 'Codex CLI',
2577
2695
  'gemini': 'Gemini CLI',
2696
+ 'grok': 'Grok CLI',
2578
2697
  'python-server': 'Python Server',
2579
2698
  'one-shot': 'One-shot'
2580
2699
  };
@@ -2624,7 +2743,18 @@
2624
2743
 
2625
2744
  if (dot) {
2626
2745
  dot.style.background = getStatusColor(meta.status);
2627
- dot.classList.toggle('pulsing', meta.status === 'thinking');
2746
+ // Sprint 50 T3 — pulse the status dot for ALL in-flight states
2747
+ // (thinking, editing, active), not just thinking. Pre-Sprint-50 the
2748
+ // dot only pulsed on `thinking`; during a long agent task the
2749
+ // status fluctuated through editing/active as different regex
2750
+ // patterns matched the live PTY stream, removing the pulsing class
2751
+ // each time and making the visual cue feel "frozen" between thinking
2752
+ // hits. Pulsing across all work-in-progress states keeps the
2753
+ // animation alive end-to-end. Idle / exited / errored stay solid.
2754
+ const inflight = meta.status === 'thinking'
2755
+ || meta.status === 'editing'
2756
+ || meta.status === 'active';
2757
+ dot.classList.toggle('pulsing', inflight);
2628
2758
  }
2629
2759
  if (status) status.textContent = meta.statusDetail || meta.status;
2630
2760
  if (metaLast && meta.lastCommands?.length) {
@@ -75,6 +75,52 @@ function statusFor(data) {
75
75
  return null;
76
76
  }
77
77
 
78
+ // ──────────────────────────────────────────────────────────────────────────
79
+ // resolveTranscriptPath — Sprint 50 T1 (10th adapter field).
80
+ //
81
+ // Claude Code stores per-session JSONL transcripts at
82
+ // ~/.claude/projects/<dir-hash>/<claude-session-uuid>.jsonl
83
+ // where <dir-hash> is `cwd` with `/` replaced by `-`. The inner UUID is
84
+ // Claude's own session id — distinct from TermDeck's `session.id` — so we
85
+ // can't compute the path; we list the directory and pick the most recently
86
+ // modified .jsonl whose mtime is at-or-after `session.meta.createdAt`.
87
+ //
88
+ // IMPORTANT: server-side `onPanelClose` SKIPS claude-typed sessions so
89
+ // Claude's existing SessionEnd hook (registered in ~/.claude/settings.json)
90
+ // remains the sole writer for Claude rows — no double-writes. This
91
+ // implementation exists for contract uniformity + the unit-test surface +
92
+ // future tooling that may want to look up Claude transcripts directly.
93
+ // ──────────────────────────────────────────────────────────────────────────
94
+
95
+ async function resolveTranscriptPath(session) {
96
+ const fs = require('fs');
97
+ const path = require('path');
98
+ const os = require('os');
99
+ if (!session || !session.meta || !session.meta.cwd) return null;
100
+ const dirHash = session.meta.cwd.replace(/\//g, '-');
101
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects', dirHash);
102
+ let entries;
103
+ try { entries = fs.readdirSync(projectsDir); }
104
+ catch (_) { return null; }
105
+ const createdAtMs = session.meta.createdAt
106
+ ? Date.parse(session.meta.createdAt)
107
+ : 0;
108
+ let bestPath = null;
109
+ let bestMtime = -Infinity;
110
+ for (const name of entries) {
111
+ if (!name.endsWith('.jsonl')) continue;
112
+ const full = path.join(projectsDir, name);
113
+ let st;
114
+ try { st = fs.statSync(full); } catch (_) { continue; }
115
+ if (createdAtMs && st.mtimeMs < createdAtMs) continue;
116
+ if (st.mtimeMs > bestMtime) {
117
+ bestMtime = st.mtimeMs;
118
+ bestPath = full;
119
+ }
120
+ }
121
+ return bestPath;
122
+ }
123
+
78
124
  // ──────────────────────────────────────────────────────────────────────────
79
125
  // parseTranscript — Claude Code JSONL format, lifted from
80
126
  // packages/stack-installer/assets/hooks/memory-session-end.js:88-102.
@@ -137,6 +183,12 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
137
183
  const claudeAdapter = {
138
184
  name: 'claude',
139
185
  sessionType: 'claude-code',
186
+ // Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
187
+ // Drives /api/agents projection and getTypeLabel() in the dashboard so a
188
+ // freshly-launched Codex/Gemini/Grok panel header reads its agent name
189
+ // (not the previous "Shell" fallback). Single source of truth — adding a
190
+ // new adapter no longer requires touching the client's hardcoded label map.
191
+ displayName: 'Claude Code',
140
192
  matches: (cmd) => typeof cmd === 'string' && /claude/i.test(cmd),
141
193
  spawn: {
142
194
  binary: 'claude',
@@ -156,6 +208,9 @@ const claudeAdapter = {
156
208
  },
157
209
  statusFor,
158
210
  parseTranscript,
211
+ // Sprint 50 T1 — 10th adapter field. See header for skip-claude rule
212
+ // (onPanelClose ignores claude-typed sessions).
213
+ resolveTranscriptPath,
159
214
  bootPromptTemplate,
160
215
  costBand: 'pay-per-token',
161
216
  // Sprint 47 T3 — Claude Code's input box accepts bracketed-paste cleanly.
@@ -101,6 +101,83 @@ function statusFor(data) {
101
101
  return null;
102
102
  }
103
103
 
104
+ // ──────────────────────────────────────────────────────────────────────────
105
+ // resolveTranscriptPath — Sprint 50 T1.
106
+ //
107
+ // Codex stores chat-shape JSONL rollouts at
108
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
109
+ // (verified 2026-05-02 substrate probe — first line is
110
+ // `{type:'session_meta', payload:{cwd, ...}}`). `~/.codex/history.jsonl` at
111
+ // the top level is a flat command-history shape, NOT chat — Sprint 49
112
+ // close-out tried that and got `session-too-short: 0 messages
113
+ // (parser=codex)` from the bundled hook against a real lane session.
114
+ //
115
+ // Attribution strategy: we don't know Codex's internal session UUID at
116
+ // spawn time, so we walk today's + yesterday's rollout directories in
117
+ // newest-mtime order, parse each file's first line, and return the first
118
+ // match where `session_meta.payload.cwd === session.meta.cwd` AND
119
+ // `mtime >= session.meta.createdAt`. Returns null when no rollout exists
120
+ // (e.g., Codex panel was opened but never sent a turn) — onPanelClose
121
+ // no-ops cleanly.
122
+ // ──────────────────────────────────────────────────────────────────────────
123
+
124
+ function _codexCandidateDirs(homedir, now) {
125
+ const path = require('path');
126
+ const day = new Date(now);
127
+ const yesterday = new Date(now - 24 * 60 * 60 * 1000);
128
+ const fmt = (d) => ({
129
+ Y: String(d.getUTCFullYear()),
130
+ M: String(d.getUTCMonth() + 1).padStart(2, '0'),
131
+ D: String(d.getUTCDate()).padStart(2, '0'),
132
+ });
133
+ const out = [];
134
+ for (const d of [day, yesterday]) {
135
+ const { Y, M, D } = fmt(d);
136
+ out.push(path.join(homedir, '.codex', 'sessions', Y, M, D));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ async function resolveTranscriptPath(session) {
142
+ const fs = require('fs');
143
+ const path = require('path');
144
+ const os = require('os');
145
+ if (!session || !session.meta || !session.meta.cwd) return null;
146
+ const cwd = session.meta.cwd;
147
+ const createdAtMs = session.meta.createdAt
148
+ ? Date.parse(session.meta.createdAt)
149
+ : 0;
150
+ const candidates = [];
151
+ for (const dir of _codexCandidateDirs(os.homedir(), Date.now())) {
152
+ let entries;
153
+ try { entries = fs.readdirSync(dir); }
154
+ catch (_) { continue; }
155
+ for (const name of entries) {
156
+ if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue;
157
+ const full = path.join(dir, name);
158
+ let st;
159
+ try { st = fs.statSync(full); } catch (_) { continue; }
160
+ if (createdAtMs && st.mtimeMs < createdAtMs) continue;
161
+ candidates.push({ full, mtime: st.mtimeMs });
162
+ }
163
+ }
164
+ candidates.sort((a, b) => b.mtime - a.mtime);
165
+ for (const { full } of candidates) {
166
+ let firstLine;
167
+ try {
168
+ const buf = fs.readFileSync(full, 'utf8');
169
+ const nl = buf.indexOf('\n');
170
+ firstLine = nl >= 0 ? buf.slice(0, nl) : buf;
171
+ } catch (_) { continue; }
172
+ let meta;
173
+ try { meta = JSON.parse(firstLine); } catch (_) { continue; }
174
+ if (!meta || meta.type !== 'session_meta') continue;
175
+ if (!meta.payload || meta.payload.cwd !== cwd) continue;
176
+ return full;
177
+ }
178
+ return null;
179
+ }
180
+
104
181
  // ──────────────────────────────────────────────────────────────────────────
105
182
  // parseTranscript — Codex JSONL format.
106
183
  //
@@ -173,6 +250,8 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
173
250
  const codexAdapter = {
174
251
  name: 'codex',
175
252
  sessionType: 'codex',
253
+ // Sprint 50 T3 — see claude.js for rationale.
254
+ displayName: 'Codex CLI',
176
255
  matches: (cmd) => typeof cmd === 'string' && /\bcodex\b/i.test(cmd),
177
256
  spawn: {
178
257
  binary: 'codex',
@@ -192,6 +271,9 @@ const codexAdapter = {
192
271
  },
193
272
  statusFor,
194
273
  parseTranscript,
274
+ // Sprint 50 T1 — 10th adapter field. See header above for substrate
275
+ // findings + attribution strategy.
276
+ resolveTranscriptPath,
195
277
  bootPromptTemplate,
196
278
  costBand: 'pay-per-token',
197
279
  // Sprint 47 T3 — Codex's Ratatui TUI accepts bracketed-paste per the
@@ -46,6 +46,59 @@ function statusFor(data) {
46
46
  return null;
47
47
  }
48
48
 
49
+ // ──────────────────────────────────────────────────────────────────────────
50
+ // resolveTranscriptPath — Sprint 50 T1.
51
+ //
52
+ // Gemini CLI persists chats at
53
+ // ~/.gemini/tmp/<basename(cwd)>/chats/session-<ISO-ts>-<short-id>.json
54
+ // (single-JSON-object shape that matches parseGeminiJson, verified
55
+ // 2026-05-02 substrate probe). Pick the most recently modified file whose
56
+ // mtime is at-or-after `session.meta.createdAt`. Falls back to walking
57
+ // every project directory under `~/.gemini/tmp/*/chats/` if the basename
58
+ // heuristic produces no candidate (e.g., Gemini renormalized the project
59
+ // name to deduplicate against an existing one).
60
+ // ──────────────────────────────────────────────────────────────────────────
61
+
62
+ async function resolveTranscriptPath(session) {
63
+ const fs = require('fs');
64
+ const path = require('path');
65
+ const os = require('os');
66
+ if (!session || !session.meta || !session.meta.cwd) return null;
67
+ const createdAtMs = session.meta.createdAt
68
+ ? Date.parse(session.meta.createdAt)
69
+ : 0;
70
+ const tmpRoot = path.join(os.homedir(), '.gemini', 'tmp');
71
+ const cwdBase = path.basename(session.meta.cwd);
72
+ const primary = path.join(tmpRoot, cwdBase, 'chats');
73
+ const extras = [];
74
+ try {
75
+ for (const proj of fs.readdirSync(tmpRoot)) {
76
+ const candidate = path.join(tmpRoot, proj, 'chats');
77
+ if (candidate !== primary) extras.push(candidate);
78
+ }
79
+ } catch (_) { /* tmp root absent */ }
80
+ let bestPath = null;
81
+ let bestMtime = -Infinity;
82
+ const scan = (dir) => {
83
+ let entries;
84
+ try { entries = fs.readdirSync(dir); } catch (_) { return; }
85
+ for (const name of entries) {
86
+ if (!name.startsWith('session-') || !name.endsWith('.json')) continue;
87
+ const full = path.join(dir, name);
88
+ let st;
89
+ try { st = fs.statSync(full); } catch (_) { continue; }
90
+ if (createdAtMs && st.mtimeMs < createdAtMs) continue;
91
+ if (st.mtimeMs > bestMtime) {
92
+ bestMtime = st.mtimeMs;
93
+ bestPath = full;
94
+ }
95
+ }
96
+ };
97
+ scan(primary);
98
+ if (!bestPath) for (const dir of extras) scan(dir);
99
+ return bestPath;
100
+ }
101
+
49
102
  // ──────────────────────────────────────────────────────────────────────────
50
103
  // parseTranscript — Gemini CLI session JSON format (NOT JSONL).
51
104
  //
@@ -177,6 +230,8 @@ function buildMnestraBlock({ secrets } = {}) {
177
230
  const geminiAdapter = {
178
231
  name: 'gemini',
179
232
  sessionType: 'gemini',
233
+ // Sprint 50 T3 — see claude.js for rationale.
234
+ displayName: 'Gemini CLI',
180
235
  matches: (cmd) => typeof cmd === 'string' && /gemini/i.test(cmd),
181
236
  spawn: {
182
237
  binary: 'gemini',
@@ -199,6 +254,8 @@ const geminiAdapter = {
199
254
  },
200
255
  statusFor,
201
256
  parseTranscript,
257
+ // Sprint 50 T1 — 10th adapter field. Walks ~/.gemini/tmp/<proj>/chats.
258
+ resolveTranscriptPath,
202
259
  bootPromptTemplate,
203
260
  costBand: 'pay-per-token',
204
261
  // Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
@@ -121,6 +121,83 @@ function statusFor(data) {
121
121
  return null;
122
122
  }
123
123
 
124
+ // ──────────────────────────────────────────────────────────────────────────
125
+ // resolveTranscriptPath — Sprint 50 T1.
126
+ //
127
+ // Grok stores messages in `~/.grok/grok.db` (SQLite, STRICT schema requiring
128
+ // SQLite ≥3.37 — macOS system sqlite3 3.36 cannot read it; better-sqlite3
129
+ // bundles a recent build). The bundled hook (vendored to ~/.claude/hooks/)
130
+ // can't `require('better-sqlite3')` because that path is outside TermDeck's
131
+ // node_modules tree. So `resolveTranscriptPath` does the SQLite extraction
132
+ // in-process here (the server has better-sqlite3 as a top-level dep), writes
133
+ // the messages as a JSON envelope to `os.tmpdir()/termdeck-grok-<id>.json`,
134
+ // and returns the tempfile path. The hook then reads that path with
135
+ // `parseGrokJson` (a flat JSON-array parser — no SQLite needed downstream).
136
+ //
137
+ // Workspace mapping: grok.db's `workspaces.canonical_path` is the agent's
138
+ // cwd-at-startup. We match against `session.meta.cwd` to find the
139
+ // workspace_id, then pick the most recent session in that workspace whose
140
+ // `created_at >= session.meta.createdAt` (allowing a small clock-skew
141
+ // epsilon). Returns null gracefully if better-sqlite3 isn't loadable, the
142
+ // DB doesn't open, the workspace isn't found, or no session matches.
143
+ // ──────────────────────────────────────────────────────────────────────────
144
+
145
+ const _GROK_RESOLVE_EPSILON_MS = 5_000;
146
+
147
+ async function resolveTranscriptPath(session) {
148
+ if (!session || !session.meta || !session.meta.cwd) return null;
149
+ const fs = require('fs');
150
+ const path = require('path');
151
+ const os = require('os');
152
+ let Database;
153
+ try { Database = require('better-sqlite3'); }
154
+ catch (_) { return null; } // dep missing → no-op
155
+ const dbPath = path.join(os.homedir(), '.grok', 'grok.db');
156
+ if (!fs.existsSync(dbPath)) return null;
157
+ let db;
158
+ try {
159
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
160
+ } catch (_) { return null; }
161
+ try {
162
+ const ws = db.prepare(
163
+ 'SELECT id FROM workspaces WHERE canonical_path = ? LIMIT 1'
164
+ ).get(session.meta.cwd);
165
+ if (!ws) return null;
166
+ const createdAtMs = session.meta.createdAt
167
+ ? Date.parse(session.meta.createdAt) - _GROK_RESOLVE_EPSILON_MS
168
+ : 0;
169
+ const grokSession = db.prepare(
170
+ 'SELECT id, created_at FROM sessions WHERE workspace_id = ? ORDER BY created_at DESC LIMIT 1'
171
+ ).get(ws.id);
172
+ if (!grokSession) return null;
173
+ if (createdAtMs && Date.parse(grokSession.created_at) < createdAtMs) {
174
+ return null; // most recent grok session predates this panel — no match
175
+ }
176
+ const rows = db.prepare(
177
+ 'SELECT message_json FROM messages WHERE session_id = ? ORDER BY seq ASC'
178
+ ).all(grokSession.id);
179
+ if (!rows || rows.length === 0) return null;
180
+ const envelope = [];
181
+ for (const row of rows) {
182
+ let parsed;
183
+ try { parsed = JSON.parse(row.message_json); } catch (_) { continue; }
184
+ if (!parsed || typeof parsed !== 'object') continue;
185
+ const role = parsed.role;
186
+ if (role !== 'user' && role !== 'assistant') continue;
187
+ envelope.push({ role, content: parsed.content });
188
+ }
189
+ if (envelope.length === 0) return null;
190
+ const safeId = String(session.id || `unknown-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, '_');
191
+ const tmpfile = path.join(os.tmpdir(), `termdeck-grok-${safeId}.json`);
192
+ fs.writeFileSync(tmpfile, JSON.stringify(envelope), 'utf8');
193
+ return tmpfile;
194
+ } catch (_) {
195
+ return null;
196
+ } finally {
197
+ try { db.close(); } catch (_) { /* fail-soft */ }
198
+ }
199
+ }
200
+
124
201
  // ──────────────────────────────────────────────────────────────────────────
125
202
  // parseTranscript — Grok stores messages in SQLite (~/.grok/grok.db), not
126
203
  // in a JSONL file. The adapter contract is `(raw: string) => Memory[]`, so
@@ -360,6 +437,8 @@ function _mergeMnestraIntoGrokSettings(rawText, { secrets } = {}) {
360
437
  const grokAdapter = {
361
438
  name: 'grok',
362
439
  sessionType: 'grok',
440
+ // Sprint 50 T3 — see claude.js for rationale.
441
+ displayName: 'Grok CLI',
363
442
  matches: (cmd) => typeof cmd === 'string' && /(?:^|\s|\/)grok(?:\b|$)/i.test(cmd),
364
443
  spawn: {
365
444
  binary: 'grok',
@@ -382,6 +461,9 @@ const grokAdapter = {
382
461
  },
383
462
  statusFor,
384
463
  parseTranscript,
464
+ // Sprint 50 T1 — 10th adapter field. SQLite extraction → tempfile JSON
465
+ // envelope (see header above for rationale + workspace mapping).
466
+ resolveTranscriptPath,
385
467
  bootPromptTemplate,
386
468
  costBand: 'subscription',
387
469
  // Sprint 47 T3 — Grok's Bun+OpenTUI input box hasn't been empirically
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const fs = require('fs');
11
11
  const dns = require('dns');
12
+ const { spawn: spawnChild } = require('child_process');
12
13
  const { v4: uuidv4 } = require('uuid');
13
14
  const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resilience');
14
15
 
@@ -128,6 +129,97 @@ function readTermdeckSecretsForPty() {
128
129
  // Test hook — clear the cache between tests that mutate the on-disk file.
129
130
  function _resetTermdeckSecretsCache() { _termdeckSecretsCache = null; }
130
131
 
132
+ // Sprint 50 T1 — Per-agent SessionEnd hook trigger.
133
+ //
134
+ // `_spawnSessionEndHookImpl` is the production spawn path; tests swap it
135
+ // out via `_setSpawnSessionEndHookImplForTesting` to capture the
136
+ // payload + arguments deterministically. The reason this indirection
137
+ // exists rather than mocking `child_process.spawn`: `node:test` doesn't
138
+ // run detached + stdio:['pipe','ignore','ignore'] children inside the
139
+ // test runner (verified — direct spawn with the same options fails to
140
+ // even invoke the script's first line). Mocking `child_process` would
141
+ // require module-level mocking which the runner doesn't support out of
142
+ // the box. A single-function injection keeps the surface tiny.
143
+ function _defaultSpawnSessionEndHookImpl(hookPath, payload, env) {
144
+ const child = spawnChild('node', [hookPath], {
145
+ stdio: ['pipe', 'ignore', 'ignore'],
146
+ detached: true,
147
+ env,
148
+ });
149
+ child.on('error', (err) => {
150
+ console.error('[onPanelClose] hook spawn error:', err && err.message ? err.message : err);
151
+ });
152
+ try {
153
+ child.stdin.write(JSON.stringify(payload));
154
+ child.stdin.end();
155
+ } catch (err) {
156
+ console.error('[onPanelClose] hook stdin write failed:', err && err.message ? err.message : err);
157
+ }
158
+ child.unref();
159
+ return child;
160
+ }
161
+ let _spawnSessionEndHookImpl = _defaultSpawnSessionEndHookImpl;
162
+ function _setSpawnSessionEndHookImplForTesting(fn) {
163
+ _spawnSessionEndHookImpl = typeof fn === 'function' ? fn : _defaultSpawnSessionEndHookImpl;
164
+ }
165
+
166
+ // Fires when a panel's PTY exits. Routes through the adapter registry's
167
+ // new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
168
+ // invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
169
+ // right payload so Codex / Gemini / Grok panels write a `session_summary`
170
+ // row the same way Claude Code already does.
171
+ //
172
+ // Skip rules (in order):
173
+ // 1. Claude — its own SessionEnd hook (registered in
174
+ // ~/.claude/settings.json) ingests Claude rows. Double-firing here
175
+ // would either insert two rows per session or race the Claude hook.
176
+ // 2. Adapters without `resolveTranscriptPath` — older adapters or types
177
+ // not in the registry (shell, python-server, one-shot). No-op.
178
+ // 3. `resolveTranscriptPath` returns null — adapter declares no
179
+ // transcript exists for this session (panel never sent a turn).
180
+ // 4. ~/.claude/hooks/memory-session-end.js missing — user hasn't
181
+ // installed the TermDeck stack hook. No-op.
182
+ //
183
+ // Fail-soft contract: any error logs to stderr and exits cleanly. Never
184
+ // blocks panel teardown — the spawn is fire-and-forget (detached + unref).
185
+ //
186
+ // `source_agent` is included in the payload (T2 consumes it via the new
187
+ // `memory_items.source_agent` column). T1 just passes the value; if T2
188
+ // hasn't migrated the column yet at the moment of first fire, Supabase
189
+ // rejects the row and the hook logs `supabase-insert-failed: HTTP 4xx`.
190
+ async function onPanelClose(session) {
191
+ try {
192
+ if (!session || !session.meta) return;
193
+ const adapter = AGENT_ADAPTERS[session.meta.type]
194
+ || Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
195
+ if (!adapter) return;
196
+ if (adapter.sessionType === 'claude-code') return;
197
+ if (typeof adapter.resolveTranscriptPath !== 'function') return;
198
+
199
+ const transcriptPath = await adapter.resolveTranscriptPath(session);
200
+ if (!transcriptPath) return;
201
+
202
+ const hookPath = path.join(os.homedir(), '.claude', 'hooks', 'memory-session-end.js');
203
+ if (!fs.existsSync(hookPath)) return;
204
+
205
+ const payload = {
206
+ transcript_path: transcriptPath,
207
+ cwd: session.meta.cwd,
208
+ session_id: session.id,
209
+ sessionType: adapter.sessionType,
210
+ // Sprint 50 — T2 consumes this via the new memory_items.source_agent column.
211
+ source_agent: adapter.name,
212
+ };
213
+
214
+ _spawnSessionEndHookImpl(hookPath, payload, {
215
+ ...process.env,
216
+ ...readTermdeckSecretsForPty(),
217
+ });
218
+ } catch (err) {
219
+ console.error('[onPanelClose] error:', err && err.message ? err.message : err);
220
+ }
221
+ }
222
+
131
223
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
132
224
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
133
225
  // them here and pass them into the helper. If a module is missing (e.g.
@@ -926,6 +1018,15 @@ function createServer(config) {
926
1018
 
927
1019
  // Fire-and-forget session log (T2.5)
928
1020
  writeSessionLog({ session, config, db, getSessionHistory });
1021
+
1022
+ // Sprint 50 T1 — fire the bundled SessionEnd hook for non-Claude
1023
+ // panels so Codex / Gemini / Grok /exits write to Mnestra the way
1024
+ // Claude Code already does. onPanelClose handles dispatch +
1025
+ // skip-claude + skip-when-no-transcript. Fire-and-forget; any
1026
+ // error logs and never blocks teardown.
1027
+ onPanelClose(session).catch((err) => {
1028
+ console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
1029
+ });
929
1030
  });
930
1031
 
931
1032
  // Wire command logging to SQLite + RAG
@@ -1324,6 +1425,9 @@ function createServer(config) {
1324
1425
  // • binary — canonical command name; client matches `^binary\b` (i)
1325
1426
  // • costBand — 'free' | 'pay-per-token' | 'subscription' (Sprint 46
1326
1427
  // surfaces this in PLANNING.md cost annotations)
1428
+ // • displayName — Sprint 50 T3: human-readable label for launcher buttons
1429
+ // and panel headers. Backwards-compat: existing clients
1430
+ // that ignore the field continue to work unchanged.
1327
1431
  // Functions / RegExps are NOT serialized — match logic lives client-side
1328
1432
  // and uses the binary as the prefix anchor. Adapter-specific shorthand
1329
1433
  // (e.g. `cc` → `claude`) is normalized in app.js before this lookup.
@@ -1333,6 +1437,29 @@ function createServer(config) {
1333
1437
  sessionType: a.sessionType,
1334
1438
  binary: a.spawn && a.spawn.binary,
1335
1439
  costBand: a.costBand,
1440
+ displayName: a.displayName || a.name,
1441
+ }));
1442
+ res.json(list);
1443
+ });
1444
+
1445
+ // GET /api/agents - Sprint 50 T3: richer adapter projection used by the
1446
+ // dashboard launcher to render one button per registered agent and by the
1447
+ // mixed-agent dogfood inject script to discover available agents. Adds
1448
+ // the full spawn descriptor (binary + defaultArgs) so callers don't need
1449
+ // to re-derive it from the binary alone. Coexists with /api/agent-adapters
1450
+ // (kept stable for the launcher-resolver client contract).
1451
+ app.get('/api/agents', (req, res) => {
1452
+ const list = Object.values(AGENT_ADAPTERS).map((a) => ({
1453
+ name: a.name,
1454
+ sessionType: a.sessionType,
1455
+ displayName: a.displayName || a.name,
1456
+ spawn: {
1457
+ binary: (a.spawn && a.spawn.binary) || a.name,
1458
+ defaultArgs: (a.spawn && Array.isArray(a.spawn.defaultArgs))
1459
+ ? a.spawn.defaultArgs.slice()
1460
+ : [],
1461
+ },
1462
+ costBand: a.costBand,
1336
1463
  }));
1337
1464
  res.json(list);
1338
1465
  });
@@ -2228,4 +2355,9 @@ module.exports = {
2228
2355
  // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2229
2356
  readTermdeckSecretsForPty,
2230
2357
  _resetTermdeckSecretsCache,
2358
+ // Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
2359
+ // hook trigger (skip-claude, no-transcript, no-hook-installed,
2360
+ // payload shape, fire-and-forget).
2361
+ onPanelClose,
2362
+ _setSpawnSessionEndHookImplForTesting,
2231
2363
  };