@jhizzard/termdeck 0.17.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.17.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"
@@ -4,6 +4,13 @@
4
4
  const WS_PROTOCOL = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
5
5
  const WS_BASE = `${WS_PROTOCOL}//${window.location.host}/ws`;
6
6
 
7
+ // ===== Utilities =====
8
+ function escapeHtml(str) {
9
+ const div = document.createElement('div');
10
+ div.textContent = str;
11
+ return div.innerHTML;
12
+ }
13
+
7
14
  // State
8
15
  const state = {
9
16
  sessions: new Map(), // id → { session, terminal, ws, fitAddon, el }
@@ -46,6 +53,20 @@
46
53
  }
47
54
  } catch (_) { /* keep bootstrap fallback */ }
48
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
+
49
70
  // Populate project dropdown
50
71
  const sel = document.getElementById('promptProject');
51
72
  for (const name of Object.keys(state.config.projects || {})) {
@@ -1579,6 +1600,98 @@
1579
1600
  launchTerminal();
1580
1601
  }
1581
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
+
1582
1695
  // ===== Add Project modal =====
1583
1696
  function rebuildProjectDropdown(selectName) {
1584
1697
  const sel = document.getElementById('promptProject');
@@ -2564,10 +2677,23 @@
2564
2677
  }
2565
2678
 
2566
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;
2567
2691
  const labels = {
2568
2692
  'shell': 'Shell',
2569
2693
  'claude-code': 'Claude Code',
2694
+ 'codex': 'Codex CLI',
2570
2695
  'gemini': 'Gemini CLI',
2696
+ 'grok': 'Grok CLI',
2571
2697
  'python-server': 'Python Server',
2572
2698
  'one-shot': 'One-shot'
2573
2699
  };
@@ -2617,7 +2743,18 @@
2617
2743
 
2618
2744
  if (dot) {
2619
2745
  dot.style.background = getStatusColor(meta.status);
2620
- 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);
2621
2758
  }
2622
2759
  if (status) status.textContent = meta.statusDetail || meta.status;
2623
2760
  if (metaLast && meta.lastCommands?.length) {
@@ -2654,12 +2791,6 @@
2654
2791
  }
2655
2792
  }
2656
2793
 
2657
- function escapeHtml(str) {
2658
- const div = document.createElement('div');
2659
- div.textContent = str;
2660
- return div.innerHTML;
2661
- }
2662
-
2663
2794
  function updateGlobalStats(sessions) {
2664
2795
  let active = 0, thinking = 0, idle = 0;
2665
2796
  for (const s of sessions) {
@@ -4257,12 +4388,6 @@
4257
4388
  dropdown.innerHTML = html;
4258
4389
  }
4259
4390
 
4260
- function escapeHtml(str) {
4261
- const div = document.createElement('div');
4262
- div.textContent = str;
4263
- return div.innerHTML;
4264
- }
4265
-
4266
4391
  function toggleHealthDropdown() {
4267
4392
  if (healthState.dropdownOpen) {
4268
4393
  closeHealthDropdown();
@@ -271,6 +271,41 @@
271
271
  margin-bottom: 16px;
272
272
  font-size: 13px;
273
273
  }
274
+
275
+ /* Pagination (Sprint 49 T2) */
276
+ .fb-pagination {
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ gap: 12px;
281
+ padding: 16px;
282
+ margin-top: 8px;
283
+ }
284
+ .fb-pag-info {
285
+ font-size: 12px;
286
+ color: var(--tg-text-dim);
287
+ font-family: var(--tg-mono);
288
+ }
289
+ .fb-pag-btn {
290
+ background: var(--tg-bg);
291
+ color: var(--tg-text);
292
+ border: 1px solid var(--tg-border);
293
+ border-radius: var(--tg-radius-sm);
294
+ padding: 4px 10px;
295
+ font-size: 12px;
296
+ font-family: var(--tg-mono);
297
+ cursor: pointer;
298
+ transition: all 0.15s;
299
+ }
300
+ .fb-pag-btn:hover:not(:disabled) {
301
+ background: var(--tg-surface-hover);
302
+ border-color: var(--tg-border-active);
303
+ color: var(--tg-text-bright);
304
+ }
305
+ .fb-pag-btn:disabled {
306
+ opacity: 0.3;
307
+ cursor: not-allowed;
308
+ }
274
309
  </style>
275
310
  </head>
276
311
  <body class="fb-page">
@@ -13,10 +13,13 @@
13
13
  // Vanilla JS, no framework — matches the rest of public/.
14
14
 
15
15
  (() => {
16
- const API = window.location.origin;
16
+ const API = window.location.origin;
17
+ const PAGE_SIZE = 25;
17
18
 
18
- const els = {
19
- windowSel: document.getElementById('fbWindow'),
19
+ let _allEvents = [];
20
+ let _currentPage = 1;
21
+
22
+ const els = { windowSel: document.getElementById('fbWindow'),
20
23
  refreshBtn: document.getElementById('fbRefresh'),
21
24
  errorBanner: document.getElementById('fbErrorBanner'),
22
25
  content: document.getElementById('fbContent'),
@@ -154,8 +157,12 @@
154
157
  `;
155
158
  }
156
159
 
157
- function renderTable(events) {
158
- const rows = events.map((e) => {
160
+ function renderTable(events, page = 1) {
161
+ const totalPages = Math.ceil(events.length / PAGE_SIZE) || 1;
162
+ const start = (page - 1) * PAGE_SIZE;
163
+ const slice = events.slice(start, start + PAGE_SIZE);
164
+
165
+ const rows = slice.map((e) => {
159
166
  const projectCell = e.project
160
167
  ? `<span class="fb-cell-project">${escapeHtml(e.project)}</span>`
161
168
  : `<span class="fb-cell-project" style="color:var(--tg-text-dim)">—</span>`;
@@ -183,7 +190,7 @@
183
190
  `;
184
191
  }).join('');
185
192
 
186
- els.content.innerHTML = `
193
+ let html = `
187
194
  <div class="fb-table-wrap">
188
195
  <table class="fb-table">
189
196
  <thead>
@@ -200,6 +207,36 @@
200
207
  </table>
201
208
  </div>
202
209
  `;
210
+
211
+ if (events.length > PAGE_SIZE) {
212
+ html += `
213
+ <div class="fb-pagination">
214
+ <button type="button" class="fb-pag-btn" id="fbPrev" ${page <= 1 ? 'disabled' : ''}>&larr; Prev</button>
215
+ <span class="fb-pag-info">Page ${page} of ${totalPages}</span>
216
+ <button type="button" class="fb-pag-btn" id="fbNext" ${page >= totalPages ? 'disabled' : ''}>Next &rarr;</button>
217
+ </div>
218
+ `;
219
+ }
220
+
221
+ els.content.innerHTML = html;
222
+
223
+ // Wire pagination buttons
224
+ const prevBtn = document.getElementById('fbPrev');
225
+ const nextBtn = document.getElementById('fbNext');
226
+ if (prevBtn) {
227
+ prevBtn.onclick = () => {
228
+ _currentPage--;
229
+ localStorage.setItem('fbHistoryPage', String(_currentPage));
230
+ renderTable(_allEvents, _currentPage);
231
+ };
232
+ }
233
+ if (nextBtn) {
234
+ nextBtn.onclick = () => {
235
+ _currentPage++;
236
+ localStorage.setItem('fbHistoryPage', String(_currentPage));
237
+ renderTable(_allEvents, _currentPage);
238
+ };
239
+ }
203
240
  }
204
241
 
205
242
  function showError(msg) {
@@ -211,7 +248,7 @@
211
248
  els.errorBanner.textContent = '';
212
249
  }
213
250
 
214
- async function refresh() {
251
+ async function refresh(resetPage = true) {
215
252
  clearError();
216
253
  els.content.innerHTML = `<div class="fb-loading">Loading flashback history…</div>`;
217
254
 
@@ -219,7 +256,7 @@
219
256
  const since = sinceFromWindow(winKey);
220
257
  const qs = new URLSearchParams();
221
258
  if (since) qs.set('since', since);
222
- qs.set('limit', '200');
259
+ qs.set('limit', '500'); // Sprint 49: raised from 200 for better pagination scale
223
260
 
224
261
  let data;
225
262
  try {
@@ -235,24 +272,34 @@
235
272
  return;
236
273
  }
237
274
 
275
+ _allEvents = data.events || [];
238
276
  renderFunnel(data.funnel || { fires: 0, dismissed: 0, clicked_through: 0 });
239
277
 
240
- if (!Array.isArray(data.events) || data.events.length === 0) {
278
+ if (_allEvents.length === 0) {
241
279
  renderZeroState(winKey);
242
280
  return;
243
281
  }
244
282
 
245
- renderTable(data.events);
283
+ if (resetPage) {
284
+ _currentPage = 1;
285
+ localStorage.setItem('fbHistoryPage', '1');
286
+ } else {
287
+ _currentPage = parseInt(localStorage.getItem('fbHistoryPage') || '1', 10);
288
+ const maxPage = Math.ceil(_allEvents.length / PAGE_SIZE) || 1;
289
+ if (_currentPage > maxPage) _currentPage = 1;
290
+ }
291
+
292
+ renderTable(_allEvents, _currentPage);
246
293
  }
247
294
 
248
295
  // Wire controls
249
296
  els.windowSel.addEventListener('change', () => {
250
297
  writeStateToUrl();
251
- refresh();
298
+ refresh(true);
252
299
  });
253
- els.refreshBtn.addEventListener('click', () => refresh());
300
+ els.refreshBtn.addEventListener('click', () => refresh(true));
254
301
 
255
302
  // Boot
256
303
  loadStateFromUrl();
257
- refresh();
304
+ refresh(false);
258
305
  })();
@@ -40,6 +40,11 @@
40
40
  </div>
41
41
  </header>
42
42
 
43
+ <div class="graph-presets" id="graphPresets" role="toolbar" aria-label="Filter presets">
44
+ <button type="button" class="gf-preset" id="presetAll">All</button>
45
+ <button type="button" class="gf-preset" id="presetNone">None</button>
46
+ </div>
47
+
43
48
  <div class="graph-filters" id="graphFilters" role="toolbar" aria-label="Edge type filters"></div>
44
49
 
45
50
  <div class="graph-filters graph-filters-row2" id="graphControls" role="toolbar" aria-label="Graph view controls">
@@ -410,6 +410,7 @@
410
410
  const keys = Object.keys(EDGE_COLORS).filter((k) => present.has(k));
411
411
  if (keys.length === 0) {
412
412
  wrap.style.display = 'none';
413
+ updatePresetButtons();
413
414
  return;
414
415
  }
415
416
  wrap.style.display = '';
@@ -429,10 +430,27 @@
429
430
  if (state.activeKinds.has(k)) state.activeKinds.delete(k);
430
431
  else state.activeKinds.add(k);
431
432
  chip.classList.toggle('active');
433
+ updatePresetButtons();
432
434
  applyFilter();
433
435
  });
434
436
  wrap.appendChild(chip);
435
437
  }
438
+ updatePresetButtons();
439
+ }
440
+
441
+ function isAllKindsActive() {
442
+ return state.activeKinds.size === Object.keys(EDGE_COLORS).length;
443
+ }
444
+
445
+ function isNoKindsActive() {
446
+ return state.activeKinds.size === 0;
447
+ }
448
+
449
+ function updatePresetButtons() {
450
+ const allBtn = $('presetAll');
451
+ const noneBtn = $('presetNone');
452
+ if (allBtn) allBtn.disabled = isAllKindsActive();
453
+ if (noneBtn) noneBtn.disabled = isNoKindsActive();
436
454
  }
437
455
 
438
456
  function applyFilter() {
@@ -843,6 +861,25 @@
843
861
  });
844
862
  $('graphFit').addEventListener('click', () => fitToView());
845
863
 
864
+ // Sprint 49 T3 — chip filter presets (All/None). Wire once; renderFilters()
865
+ // keeps their disabled state in sync with activeKinds boundary conditions.
866
+ const presetAll = $('presetAll');
867
+ const presetNone = $('presetNone');
868
+ if (presetAll) {
869
+ presetAll.addEventListener('click', () => {
870
+ state.activeKinds = new Set(Object.keys(EDGE_COLORS));
871
+ renderFilters();
872
+ applyFilter();
873
+ });
874
+ }
875
+ if (presetNone) {
876
+ presetNone.addEventListener('click', () => {
877
+ state.activeKinds = new Set();
878
+ renderFilters();
879
+ applyFilter();
880
+ });
881
+ }
882
+
846
883
  // Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
847
884
  // wire change handlers that mutate state.controls + URL + re-render from
848
885
  // the cached raw fetch so toggling is fast (no API round-trip).
@@ -58,9 +58,8 @@
58
58
  <button class="topbar-ql-btn" onclick="quickLaunch('python3 -m http.server 8080')" title="Open a Python HTTP server on :8080">python</button>
59
59
  </div>
60
60
  <div class="topbar-row-2-spacer"></div>
61
- <button id="btn-status">status</button>
62
- <button id="btn-config">config</button>
63
- <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
61
+ <button id="btn-status" title="Global metrics: session counts, RAG mode, and memory bridge status">status</button>
62
+ <button id="btn-config" title="Configuration: project list, theme defaults, and live RAG-mode toggle">config</button> <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
64
63
  <button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
65
64
  <button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
66
65
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
@@ -24,6 +24,10 @@
24
24
  root.LauncherResolver = factory();
25
25
  }
26
26
  })(typeof self !== 'undefined' ? self : this, function () {
27
+ function escapeRegex(s) {
28
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+
27
31
  function resolve(command, project, agentAdapters, projects) {
28
32
  let resolvedCommand = command;
29
33
  let resolvedType = 'shell';
@@ -36,7 +40,7 @@
36
40
  }
37
41
 
38
42
  const adapter = (agentAdapters || []).find((a) =>
39
- a && a.binary && new RegExp(`^${a.binary}\\b`, 'i').test(canonical)
43
+ a && a.binary && new RegExp(`^${escapeRegex(a.binary)}\\b`, 'i').test(canonical)
40
44
  );
41
45
 
42
46
  if (adapter) {
@@ -3567,6 +3567,37 @@
3567
3567
  background: rgba(255, 255, 255, 0.08);
3568
3568
  }
3569
3569
 
3570
+ /* Sprint 49 T3 — graph chip filter presets (Surface 2 from Sprint 46 audit).
3571
+ Subtle variant of .gf-chip: smaller, no dot/count, secondary tint,
3572
+ disabled state for boundary (all/none already active). Placed above
3573
+ the chip row. */
3574
+ .graph-presets {
3575
+ display: flex;
3576
+ gap: 6px;
3577
+ padding: 8px 16px 4px;
3578
+ background: var(--tg-surface);
3579
+ border-bottom: 1px solid var(--tg-border);
3580
+ flex-shrink: 0;
3581
+ }
3582
+ .gf-preset {
3583
+ padding: 2px 10px;
3584
+ font-size: 10px;
3585
+ font-family: var(--tg-mono);
3586
+ color: var(--tg-text-dim);
3587
+ background: var(--tg-bg);
3588
+ border: 1px solid var(--tg-border);
3589
+ border-radius: 999px;
3590
+ cursor: pointer;
3591
+ transition: all 150ms;
3592
+ opacity: 0.7;
3593
+ }
3594
+ .gf-preset:hover { opacity: 1; background: var(--tg-surface-hover); }
3595
+ .gf-preset:disabled {
3596
+ opacity: 0.35;
3597
+ cursor: not-allowed;
3598
+ background: transparent;
3599
+ }
3600
+
3570
3601
  /* Sprint 43 T1 — second toolbar row: hide-isolated / min-degree / window
3571
3602
  / layout selectors. Mirrors .graph-filters spacing but shifts the
3572
3603
  background a notch darker so the two rows are visually distinguishable
@@ -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
  };