@jhizzard/termdeck 0.16.1 → 0.18.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.16.1",
3
+ "version": "0.18.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 }
@@ -2654,12 +2661,6 @@
2654
2661
  }
2655
2662
  }
2656
2663
 
2657
- function escapeHtml(str) {
2658
- const div = document.createElement('div');
2659
- div.textContent = str;
2660
- return div.innerHTML;
2661
- }
2662
-
2663
2664
  function updateGlobalStats(sessions) {
2664
2665
  let active = 0, thinking = 0, idle = 0;
2665
2666
  for (const s of sessions) {
@@ -4257,12 +4258,6 @@
4257
4258
  dropdown.innerHTML = html;
4258
4259
  }
4259
4260
 
4260
- function escapeHtml(str) {
4261
- const div = document.createElement('div');
4262
- div.textContent = str;
4263
- return div.innerHTML;
4264
- }
4265
-
4266
4261
  function toggleHealthDropdown() {
4267
4262
  if (healthState.dropdownOpen) {
4268
4263
  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
@@ -7,7 +7,8 @@
7
7
  // transcript-parser cut-offs. Sprint 45 adds Codex / Gemini / Grok adapters
8
8
  // alongside this one; Sprint 46 wires per-lane agent assignment in 4+1.
9
9
  //
10
- // Contract (memorialization doc § 4 + lane brief T3):
10
+ // Contract (memorialization doc § 4 + lane brief T3, extended in Sprint 47
11
+ // T3 with `acceptsPaste` and Sprint 48 T1 with `mcpConfig`):
11
12
  // {
12
13
  // name: string, // adapter id used in registry
13
14
  // sessionType: string, // session.meta.type produced
@@ -19,6 +20,10 @@
19
20
  // parseTranscript:(raw) => Memory[], // for memory-session-end hook
20
21
  // bootPromptTemplate: (lane, sprint) => string,
21
22
  // costBand: 'free' | 'pay-per-token' | 'subscription',
23
+ // acceptsPaste: boolean, // Sprint 47 T3 — bracketed-paste capable
24
+ // mcpConfig: { path, format, mnestraBlock, detectExisting } | null,
25
+ // // Sprint 48 T1 — per-agent MCP auto-wire
26
+ // // null = user-managed (Claude only)
22
27
  // }
23
28
  //
24
29
  // `statusFor` returns null when no pattern matches — preserves the original
@@ -157,6 +162,11 @@ const claudeAdapter = {
157
162
  // The two-stage submit pattern (paste then \r alone) is the canonical inject
158
163
  // shape for this adapter; chunked-fallback is unnecessary.
159
164
  acceptsPaste: true,
165
+ // Sprint 48 T1 — Claude's MCP config (~/.claude.json) is owned by the user
166
+ // and `claude mcp add`. Auto-wiring a Mnestra block here would conflict
167
+ // with that surface. `null` declares the contract field while signalling
168
+ // "user-managed; mcp-autowire.js short-circuits to skipped:no-mcpConfig".
169
+ mcpConfig: null,
160
170
  };
161
171
 
162
172
  module.exports = claudeAdapter;
@@ -197,6 +197,36 @@ const codexAdapter = {
197
197
  // Sprint 47 T3 — Codex's Ratatui TUI accepts bracketed-paste per the
198
198
  // Sprint 45 T1 audit; safe to use the two-stage submit pattern unchanged.
199
199
  acceptsPaste: true,
200
+ // Sprint 48 T1 — per-agent MCP auto-wire descriptor consumed by
201
+ // packages/server/src/mcp-autowire.js. Codex reads MCP servers from
202
+ // ~/.codex/config.toml in the canonical `[mcp_servers.NAME]` shape with a
203
+ // sibling `[mcp_servers.NAME.env]` table (snake_case, NOT camelCase — that
204
+ // distinguishes Codex's TOML schema from the JSON-based agents).
205
+ mcpConfig: {
206
+ path: '~/.codex/config.toml',
207
+ format: 'toml',
208
+ mnestraBlock: ({ secrets }) => {
209
+ const lines = ['[mcp_servers.mnestra]', 'command = "mnestra"'];
210
+ const wanted = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
211
+ const env = {};
212
+ for (const k of wanted) {
213
+ if (secrets && typeof secrets[k] === 'string' && secrets[k].length > 0) {
214
+ env[k] = secrets[k];
215
+ }
216
+ }
217
+ if (Object.keys(env).length > 0) {
218
+ lines.push('');
219
+ lines.push('[mcp_servers.mnestra.env]');
220
+ for (const [k, v] of Object.entries(env)) {
221
+ // TOML basic-string escaping — backslash + double-quote.
222
+ const escaped = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
223
+ lines.push(`${k} = "${escaped}"`);
224
+ }
225
+ }
226
+ return lines.join('\n') + '\n';
227
+ },
228
+ detectExisting: (text) => /^\s*\[mcp_servers\.mnestra\]\s*$/m.test(text),
229
+ },
200
230
  };
201
231
 
202
232
  module.exports = codexAdapter;
@@ -126,6 +126,54 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
126
126
  ].join('\n');
127
127
  }
128
128
 
129
+ // ──────────────────────────────────────────────────────────────────────────
130
+ // mcpConfig — Sprint 48 T2. Declarative description of where Gemini reads
131
+ // its MCP-server registry and how to write a Mnestra entry into it. The
132
+ // shared helper at packages/server/src/mcp-autowire.js (Sprint 48 T1) uses
133
+ // this on panel spawn to ensure `memory_recall` is available out-of-the-box
134
+ // for outside users running mixed 4+1 with a Gemini lane.
135
+ //
136
+ // Schema reference: https://www.geminicli.com/docs/tools/mcp-server
137
+ // (verified 2026-05-02). Top-level key is `mcpServers` (camelCase). Each
138
+ // entry must specify exactly one transport — `command` (stdio), `url`
139
+ // (SSE), or `httpUrl` (HTTP streaming). Mnestra ships as a stdio binary
140
+ // (`mnestra`), so we use `command`.
141
+ //
142
+ // Note (no `type` field): the `type: 'stdio'` field used in the Claude
143
+ // Code config (~/.claude.json `mcp_servers.mnestra.type`) is a Claude-Code
144
+ // extension. Gemini infers transport from which of command/url/httpUrl is
145
+ // set, so we omit `type` here to keep the entry valid against the
146
+ // documented Gemini schema.
147
+ //
148
+ // Note (restart required): Gemini CLI discovers MCP servers at startup, so
149
+ // adding a new entry only takes effect on the next `gemini` launch. The
150
+ // helper still writes immediately on panel spawn — by the time the user
151
+ // types `gemini` in the panel, the entry is in place.
152
+ //
153
+ // Note (env-key omission): empty/missing secrets are intentionally
154
+ // dropped from the env object instead of written as empty strings. This
155
+ // matches stack-installer/src/index.js:336-339 — concrete-or-omit, never
156
+ // placeholder, because Gemini (like Claude Code) does not shell-expand
157
+ // `${VAR}` references in MCP env. Mnestra's own secrets.env fallback
158
+ // loads what's missing at process start.
159
+ // ──────────────────────────────────────────────────────────────────────────
160
+
161
+ const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
162
+
163
+ function buildMnestraBlock({ secrets } = {}) {
164
+ const env = {};
165
+ for (const key of MNESTRA_ENV_KEYS) {
166
+ const value = secrets && secrets[key];
167
+ if (value) env[key] = value;
168
+ }
169
+ return {
170
+ mnestra: {
171
+ command: 'mnestra',
172
+ env,
173
+ },
174
+ };
175
+ }
176
+
129
177
  const geminiAdapter = {
130
178
  name: 'gemini',
131
179
  sessionType: 'gemini',
@@ -156,6 +204,13 @@ const geminiAdapter = {
156
204
  // Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
157
205
  // session shape captured in Sprint 45 T2; bracketed-paste injects cleanly.
158
206
  acceptsPaste: true,
207
+ // Sprint 48 T2 — see comment block above for schema notes + provenance.
208
+ mcpConfig: {
209
+ path: '~/.gemini/settings.json',
210
+ format: 'json',
211
+ mcpServersKey: 'mcpServers',
212
+ mnestraBlock: buildMnestraBlock,
213
+ },
159
214
  };
160
215
 
161
216
  module.exports = geminiAdapter;
@@ -218,6 +218,136 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
218
218
  ].join('\n');
219
219
  }
220
220
 
221
+ // ──────────────────────────────────────────────────────────────────────────
222
+ // mcpConfig — Sprint 48 T3. Grok's MCP-server registry lives at
223
+ // `~/.grok/user-settings.json` under the `mcp.servers` key, which is an
224
+ // **ARRAY** of `McpServerConfig` items, NOT a record `mcpServers.NAME` like
225
+ // Codex/Gemini use. Authoritative schema lifted from
226
+ // `/usr/local/lib/node_modules/grok-dev/dist/utils/settings.{d.ts,js}`
227
+ // (Bun-bundled source, package `grok-dev` v1.1.5):
228
+ //
229
+ // interface McpServerConfig {
230
+ // id: string; label: string; enabled: boolean;
231
+ // transport: "http" | "sse" | "stdio";
232
+ // command?, args?, env?, cwd?, url?, headers?
233
+ // }
234
+ // interface McpSettings { servers?: McpServerConfig[] }
235
+ // interface UserSettings { ..., mcp?: McpSettings }
236
+ // function loadMcpServers(): UserSettings.mcp?.servers ?? []
237
+ // function saveMcpServers(servers): saveUserSettings({ mcp: { servers } })
238
+ //
239
+ // Hot-load behavior: agent.js calls `loadMcpServers()` at the start of every
240
+ // agent turn (3 sites: stream / batch / child-agent), so MCP changes are
241
+ // picked up on the next user message — no Grok restart required.
242
+ //
243
+ // Schema-divergence implication: the `mcpServersKey + mnestraBlock` record-
244
+ // merge shape used by gemini.js (Sprint 48 T2) and the TOML-append shape used
245
+ // by codex.js cannot represent Grok's array-with-explicit-id-fields layout.
246
+ // Grok therefore declares a `merge(rawText, { secrets }) -> { changed, output }`
247
+ // escape-hatch on its `mcpConfig`. The shared `mcp-autowire.js` helper
248
+ // (Sprint 48 T1) checks for `mcpConfig.merge` first; if present, the adapter
249
+ // owns parse + mutate + serialize, the helper still owns tilde-expansion +
250
+ // parent-dir creation + atomic write + idempotency reporting. See Sprint 48
251
+ // STATUS.md § T3 FIX-PROPOSED for the coordination decision.
252
+ //
253
+ // Env-key omission discipline matches stack-installer/src/index.js:336-339
254
+ // and the Gemini adapter: empty/missing/`${VAR}`-placeholder values are
255
+ // dropped from the env object instead of written as empty strings, because
256
+ // Grok (like Claude Code and Gemini) does not shell-expand `${VAR}` in MCP
257
+ // env. Mnestra's own secrets.env stdio fallback (mnestra@0.3.4) loads what
258
+ // is missing at process start.
259
+ // ──────────────────────────────────────────────────────────────────────────
260
+
261
+ const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
262
+
263
+ function _pickConcreteEnv(secrets) {
264
+ const env = {};
265
+ if (!secrets || typeof secrets !== 'object') return env;
266
+ for (const key of MNESTRA_ENV_KEYS) {
267
+ const value = secrets[key];
268
+ if (typeof value !== 'string') continue;
269
+ if (value.length === 0) continue;
270
+ // Reject literal `${VAR}` placeholders — Grok won't shell-expand them.
271
+ if (/^\$\{[^}]*\}$/.test(value)) continue;
272
+ env[key] = value;
273
+ }
274
+ return env;
275
+ }
276
+
277
+ function _buildMnestraServer({ secrets } = {}) {
278
+ return {
279
+ id: 'mnestra',
280
+ label: 'Mnestra',
281
+ enabled: true,
282
+ transport: 'stdio',
283
+ command: 'mnestra',
284
+ args: [],
285
+ env: _pickConcreteEnv(secrets),
286
+ };
287
+ }
288
+
289
+ // Deep-equal check scoped to the fields we manage. Unknown extra fields on
290
+ // the existing entry (e.g. user-added `cwd` overrides) are tolerated — we
291
+ // only refresh the entry when one of OUR managed fields drifts. Prevents
292
+ // the helper from clobbering hand-edited Grok customizations on every spawn.
293
+ function _mnestraEntryEqual(existing, desired) {
294
+ if (!existing || typeof existing !== 'object') return false;
295
+ for (const key of ['id', 'label', 'enabled', 'transport', 'command']) {
296
+ if (existing[key] !== desired[key]) return false;
297
+ }
298
+ const a = Array.isArray(existing.args) ? existing.args : [];
299
+ const b = desired.args;
300
+ if (a.length !== b.length) return false;
301
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
302
+ const ea = existing.env && typeof existing.env === 'object' ? existing.env : {};
303
+ const eb = desired.env;
304
+ const eaKeys = Object.keys(ea).sort();
305
+ const ebKeys = Object.keys(eb).sort();
306
+ if (eaKeys.length !== ebKeys.length) return false;
307
+ for (let i = 0; i < eaKeys.length; i += 1) {
308
+ if (eaKeys[i] !== ebKeys[i]) return false;
309
+ if (ea[eaKeys[i]] !== eb[ebKeys[i]]) return false;
310
+ }
311
+ return true;
312
+ }
313
+
314
+ function _mergeMnestraIntoGrokSettings(rawText, { secrets } = {}) {
315
+ let current = {};
316
+ if (typeof rawText === 'string' && rawText.trim().length > 0) {
317
+ try {
318
+ const parsed = JSON.parse(rawText);
319
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
320
+ current = parsed;
321
+ }
322
+ } catch (_) {
323
+ // Malformed JSON → start fresh. Helper's atomic-write contract means
324
+ // we don't risk corrupting the user's file partway through; on read
325
+ // failure the conservative path is to write a clean replacement that
326
+ // preserves the keys we know how to round-trip (none — we only own
327
+ // the mcp branch). User's other settings in a corrupt file are
328
+ // unrecoverable from text anyway.
329
+ current = {};
330
+ }
331
+ }
332
+ const next = { ...current };
333
+ next.mcp = next.mcp && typeof next.mcp === 'object' && !Array.isArray(next.mcp)
334
+ ? { ...next.mcp }
335
+ : {};
336
+ const servers = Array.isArray(next.mcp.servers) ? [...next.mcp.servers] : [];
337
+ const desired = _buildMnestraServer({ secrets });
338
+ const existingIdx = servers.findIndex((s) => s && s.id === 'mnestra');
339
+ if (existingIdx >= 0 && _mnestraEntryEqual(servers[existingIdx], desired)) {
340
+ return { changed: false, output: rawText };
341
+ }
342
+ if (existingIdx >= 0) {
343
+ servers[existingIdx] = desired;
344
+ } else {
345
+ servers.push(desired);
346
+ }
347
+ next.mcp.servers = servers;
348
+ return { changed: true, output: `${JSON.stringify(next, null, 2)}\n` };
349
+ }
350
+
221
351
  // ──────────────────────────────────────────────────────────────────────────
222
352
  // Adapter export. spawn.env.GROK_MODEL defaults to the cheap-fast tier;
223
353
  // per-lane override is the launcher's job at session-spawn time (Sprint 46
@@ -260,6 +390,15 @@ const grokAdapter = {
260
390
  // lane-time test shows the OpenTUI input handler eats the paste markers,
261
391
  // flip this to false and the inject helper falls back to chunked stdin.
262
392
  acceptsPaste: true,
393
+ // Sprint 48 T3 — see comment block above for schema notes + provenance.
394
+ // Grok deviates from Codex (TOML) and Gemini (JSON record) — its `mcp.servers`
395
+ // is an array with explicit `id`/`label`/`enabled`/`transport` fields, so the
396
+ // adapter declares a `merge` escape-hatch instead of `mcpServersKey + mnestraBlock`.
397
+ mcpConfig: {
398
+ path: '~/.grok/user-settings.json',
399
+ format: 'json',
400
+ merge: _mergeMnestraIntoGrokSettings,
401
+ },
263
402
  };
264
403
 
265
404
  module.exports = grokAdapter;
@@ -81,6 +81,53 @@ const orchestrationPreview = require('./orchestration-preview');
81
81
  const { createPtyReaper } = require('./pty-reaper');
82
82
  const { AGENT_ADAPTERS } = require('./agent-adapters');
83
83
 
84
+ // Sprint 48 T4 deliverable 2: PTY env-var propagation.
85
+ // Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
86
+ // inherits SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / OPENAI_API_KEY etc.
87
+ // without depending on the user's shell to have sourced the file.
88
+ //
89
+ // Why this exists: `memory-session-end.js` (the bundled Stop hook installed by
90
+ // `@jhizzard/termdeck-stack`) writes session_summary rows to Mnestra by
91
+ // reading those three vars from `process.env`. When TermDeck spawns a Claude
92
+ // Code panel directly via `pty.spawn`, the child shell inherits the server's
93
+ // `process.env` — but if the *user* didn't source secrets.env in their
94
+ // `.zshrc` before running `termdeck`, those vars are absent and every session
95
+ // close hits `env-var-missing`. Sprint 47 close-out audit confirmed 0
96
+ // session_summary rows had ever landed.
97
+ //
98
+ // Treats `${VAR}` placeholders as unset (Sprint 47.5 hotfix lesson — Claude
99
+ // Code does not shell-expand MCP env values; same trap applies anywhere the
100
+ // secrets file flows through a non-shell consumer).
101
+ let _termdeckSecretsCache = null;
102
+ function readTermdeckSecretsForPty() {
103
+ if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
104
+ const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
105
+ const out = {};
106
+ try {
107
+ const text = fs.readFileSync(secretsPath, 'utf8');
108
+ for (const raw of text.split('\n')) {
109
+ const line = raw.trim();
110
+ if (!line || line.startsWith('#')) continue;
111
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
112
+ if (!m) continue;
113
+ let v = m[2].trim();
114
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
115
+ v = v.slice(1, -1);
116
+ }
117
+ if (v.startsWith('${') && v.endsWith('}')) continue;
118
+ if (v === '') continue;
119
+ out[m[1]] = v;
120
+ }
121
+ } catch (_err) {
122
+ // File absent or unreadable — empty merge, hook still hits env-var-missing
123
+ // until the user runs the wizard. Better than a crash on spawn.
124
+ }
125
+ _termdeckSecretsCache = out;
126
+ return out;
127
+ }
128
+ // Test hook — clear the cache between tests that mutate the on-disk file.
129
+ function _resetTermdeckSecretsCache() { _termdeckSecretsCache = null; }
130
+
84
131
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
85
132
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
86
133
  // them here and pass them into the helper. If a module is missing (e.g.
@@ -805,6 +852,18 @@ function createServer(config) {
805
852
  const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
806
853
 
807
854
  try {
855
+ // Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
856
+ // the bundled session-end memory hook (`memory-session-end.js`) sees
857
+ // SUPABASE_URL / SERVICE_ROLE_KEY / OPENAI_API_KEY without depending
858
+ // on the user's shell to have sourced the file. process.env is the
859
+ // base; any concrete value the parent already exported wins.
860
+ const termdeckSecrets = readTermdeckSecretsForPty();
861
+ const secretFallback = {};
862
+ for (const [k, v] of Object.entries(termdeckSecrets)) {
863
+ if (process.env[k] === undefined || process.env[k] === '') {
864
+ secretFallback[k] = v;
865
+ }
866
+ }
808
867
  const term = pty.spawn(spawnShell, args, {
809
868
  name: 'xterm-256color',
810
869
  cols: 120,
@@ -812,6 +871,7 @@ function createServer(config) {
812
871
  cwd: resolvedCwd,
813
872
  env: {
814
873
  ...process.env,
874
+ ...secretFallback,
815
875
  TERMDECK_SESSION: session.id,
816
876
  TERMDECK_PROJECT: project || '',
817
877
  TERM: 'xterm-256color',
@@ -2162,4 +2222,10 @@ if (require.main === module) {
2162
2222
  });
2163
2223
  }
2164
2224
 
2165
- module.exports = { createServer, loadConfig };
2225
+ module.exports = {
2226
+ createServer,
2227
+ loadConfig,
2228
+ // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2229
+ readTermdeckSecretsForPty,
2230
+ _resetTermdeckSecretsCache,
2231
+ };
@@ -0,0 +1,253 @@
1
+ // Sprint 48 T1 — Shared per-agent MCP auto-wire helper.
2
+ //
3
+ // Single export: ensureMnestraBlock(adapter, opts?). Idempotent. T1/T2/T3
4
+ // agent adapters (codex, gemini, grok) each ship an `mcpConfig` field
5
+ // describing where their MCP-server config lives, what format it's in,
6
+ // and how to merge a Mnestra entry into it. This helper is the agent-
7
+ // agnostic glue: read the file, dispatch on shape, render+merge+write
8
+ // using the secrets in ~/.termdeck/secrets.env.
9
+ //
10
+ // Why this exists: cross-project memory recall (Mnestra MCP) was unavailable
11
+ // to non-Claude agents by default in Sprint 47's Grok smoke — those CLIs
12
+ // ship without an MCP block and outside users would hit memory_recall
13
+ // failures the first time they spawned a non-Claude lane. This is the
14
+ // v1.0.0 gate-blocker fix.
15
+ //
16
+ // Three adapter shapes are supported (precedence top → bottom):
17
+ //
18
+ // 1. Escape-hatch (Grok-style — array-shape JSON or anything bespoke):
19
+ // mcpConfig: { path, format, merge: (rawText, {secrets}) =>
20
+ // ({ changed: bool, output: string }) }
21
+ // Adapter owns parse + mutate + serialize entirely. Helper still owns
22
+ // tilde expand, mkdir, read, atomic write, return shape.
23
+ //
24
+ // 2. JSON-record (Gemini-style — `{mcpServers: {NAME: {...}}}`):
25
+ // mcpConfig: { path, format: 'json', mcpServersKey: 'mcpServers',
26
+ // mnestraBlock: ({secrets}) => ({mnestra: {command, env}}) }
27
+ // Helper deep-merges the returned object under `config[mcpServersKey]`.
28
+ // Existence detected by checking `existing[mcpServersKey]?.mnestra`.
29
+ //
30
+ // 3. TOML-append (Codex-style — `[mcp_servers.NAME]` tables):
31
+ // mcpConfig: { path, format: 'toml',
32
+ // mnestraBlock: ({secrets}) => '[mcp_servers.mnestra]\n...',
33
+ // detectExisting: (text) => /\[mcp_servers\.mnestra\]/m.test(text) }
34
+ // Helper appends the rendered string to the file with one blank-line
35
+ // separator. Idempotent via the adapter's `detectExisting` predicate.
36
+ //
37
+ // Claude is intentionally exempt — its MCP config (~/.claude.json) is
38
+ // owned by the user and `claude mcp add`. Adding a Mnestra block here
39
+ // would conflict with that surface. Claude's adapter declares
40
+ // `mcpConfig: null` to satisfy the contract-parity tests.
41
+
42
+ 'use strict';
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const os = require('node:os');
47
+
48
+ const SECRETS_PATH = path.join(os.homedir(), '.termdeck', 'secrets.env');
49
+
50
+ function expandTilde(p) {
51
+ if (typeof p !== 'string') return p;
52
+ if (p === '~') return os.homedir();
53
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
54
+ return p;
55
+ }
56
+
57
+ // dotenv-subset parser. Mirrors stack-installer's readTermdeckSecrets so the
58
+ // two stay byte-equivalent (KEY=value, optional matched single/double quotes,
59
+ // `#` comments, blanks ignored). Returns {} on absent / unreadable file.
60
+ // Rejects literal `${VAR}` placeholder shapes — same defense as the mnestra
61
+ // MCP stdio fallback (Claude Code et al. don't shell-expand them, so writing
62
+ // the literal placeholder is worse than omitting the key entirely).
63
+ function readSecrets(secretsPath = SECRETS_PATH) {
64
+ try {
65
+ const text = fs.readFileSync(secretsPath, 'utf8');
66
+ const out = {};
67
+ for (const raw of text.split('\n')) {
68
+ const line = raw.trim();
69
+ if (!line || line.startsWith('#')) continue;
70
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
71
+ if (!m) continue;
72
+ let v = m[2];
73
+ if (
74
+ v.length >= 2
75
+ && (v[0] === '"' || v[0] === "'")
76
+ && v[v.length - 1] === v[0]
77
+ ) {
78
+ v = v.slice(1, -1);
79
+ }
80
+ if (v.startsWith('${') && v.endsWith('}')) continue;
81
+ out[m[1]] = v;
82
+ }
83
+ return out;
84
+ } catch (_err) {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ // One-level-deep merge sufficient for the `mcpServers.NAME` shape. Nested
90
+ // objects under matching keys are themselves merged shallowly; arrays +
91
+ // primitives are replaced.
92
+ function mergeJson(base, addition) {
93
+ const out = { ...base };
94
+ for (const [k, v] of Object.entries(addition)) {
95
+ if (
96
+ v && typeof v === 'object' && !Array.isArray(v)
97
+ && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])
98
+ ) {
99
+ out[k] = { ...out[k], ...v };
100
+ } else {
101
+ out[k] = v;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // Append a TOML block to existing file content with one blank-line separator
108
+ // (or none if the file is empty). Codex's TOML parser accepts tables in any
109
+ // order so appending is the safe operation; we don't try to surgically
110
+ // rewrite mid-file.
111
+ function appendTomlBlock(existing, block) {
112
+ const trailing = existing.endsWith('\n') ? '' : '\n';
113
+ const sep = existing.length === 0 ? '' : trailing + '\n';
114
+ const blockTail = block.endsWith('\n') ? '' : '\n';
115
+ return existing + sep + block + blockTail;
116
+ }
117
+
118
+ // Detect whether a JSON-shape object already has a Mnestra entry under
119
+ // `mcpServersKey`. Tolerant of the key being absent or non-object.
120
+ function jsonAlreadyHasMnestra(parsedConfig, mcpServersKey) {
121
+ const bag = parsedConfig && parsedConfig[mcpServersKey];
122
+ return !!(bag && typeof bag === 'object' && !Array.isArray(bag) && bag.mnestra);
123
+ }
124
+
125
+ // Idempotent. Returns one of:
126
+ // { skipped: true, reason: '...' } — adapter omits or malforms mcpConfig
127
+ // { unchanged: true, path } — block already present
128
+ // { wrote: true, path, bytes } — block written / appended
129
+ //
130
+ // opts.secretsPath overrides the default ~/.termdeck/secrets.env (used by
131
+ // tests); opts.secrets passes a pre-parsed object directly (also tests).
132
+ function ensureMnestraBlock(adapter, opts = {}) {
133
+ if (!adapter || !adapter.mcpConfig) {
134
+ return { skipped: true, reason: 'no-mcpConfig' };
135
+ }
136
+ const cfg = adapter.mcpConfig;
137
+ if (typeof cfg.path !== 'string') {
138
+ return { skipped: true, reason: 'malformed-mcpConfig' };
139
+ }
140
+
141
+ const useMerge = typeof cfg.merge === 'function';
142
+ const useJsonRecord = !useMerge
143
+ && cfg.format === 'json'
144
+ && typeof cfg.mcpServersKey === 'string'
145
+ && typeof cfg.mnestraBlock === 'function';
146
+ const useTomlAppend = !useMerge && !useJsonRecord
147
+ && cfg.format === 'toml'
148
+ && typeof cfg.mnestraBlock === 'function'
149
+ && typeof cfg.detectExisting === 'function';
150
+ const useJsonAppend = !useMerge && !useJsonRecord && !useTomlAppend
151
+ && cfg.format === 'json'
152
+ && typeof cfg.mnestraBlock === 'function'
153
+ && typeof cfg.detectExisting === 'function';
154
+
155
+ if (!useMerge && !useJsonRecord && !useTomlAppend && !useJsonAppend) {
156
+ return { skipped: true, reason: 'malformed-mcpConfig' };
157
+ }
158
+
159
+ const target = expandTilde(cfg.path);
160
+ const dir = path.dirname(target);
161
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
162
+
163
+ let existing = '';
164
+ try { existing = fs.readFileSync(target, 'utf8'); } catch (_) { existing = ''; }
165
+
166
+ const secrets = opts.secrets || readSecrets(opts.secretsPath || SECRETS_PATH);
167
+
168
+ // 1. Escape-hatch — adapter owns the merge entirely.
169
+ if (useMerge) {
170
+ let result;
171
+ try { result = cfg.merge(existing, { secrets, adapter }); }
172
+ catch (e) { return { skipped: true, reason: `merge-threw-${e.message}` }; }
173
+ if (!result || typeof result !== 'object') {
174
+ return { skipped: true, reason: 'merge-bad-return' };
175
+ }
176
+ if (!result.changed) return { unchanged: true, path: target };
177
+ if (typeof result.output !== 'string') {
178
+ return { skipped: true, reason: 'merge-output-not-string' };
179
+ }
180
+ fs.writeFileSync(target, result.output, { mode: 0o600 });
181
+ return { wrote: true, path: target, bytes: Buffer.byteLength(result.output) };
182
+ }
183
+
184
+ // 2. JSON record-merge (Gemini shape).
185
+ if (useJsonRecord) {
186
+ let parsed = {};
187
+ if (existing.trim() !== '') {
188
+ try { parsed = JSON.parse(existing); }
189
+ catch (_) { return { skipped: true, reason: 'existing-json-malformed' }; }
190
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
191
+ }
192
+ if (jsonAlreadyHasMnestra(parsed, cfg.mcpServersKey)) {
193
+ return { unchanged: true, path: target };
194
+ }
195
+ let block;
196
+ try { block = cfg.mnestraBlock({ secrets, adapter }); }
197
+ catch (e) { return { skipped: true, reason: `mnestraBlock-threw-${e.message}` }; }
198
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
199
+ return { skipped: true, reason: 'mnestraBlock-not-object' };
200
+ }
201
+ const next = { ...parsed };
202
+ const bag = (next[cfg.mcpServersKey] && typeof next[cfg.mcpServersKey] === 'object'
203
+ && !Array.isArray(next[cfg.mcpServersKey]))
204
+ ? { ...next[cfg.mcpServersKey] }
205
+ : {};
206
+ Object.assign(bag, block);
207
+ next[cfg.mcpServersKey] = bag;
208
+ const serialized = JSON.stringify(next, null, 2) + '\n';
209
+ fs.writeFileSync(target, serialized, { mode: 0o600 });
210
+ return { wrote: true, path: target, bytes: Buffer.byteLength(serialized) };
211
+ }
212
+
213
+ // 3 & 4. detectExisting + mnestraBlock-string paths (TOML or JSON-append).
214
+ if (cfg.detectExisting(existing)) {
215
+ return { unchanged: true, path: target };
216
+ }
217
+ let block;
218
+ try { block = cfg.mnestraBlock({ secrets, adapter }); }
219
+ catch (e) { return { skipped: true, reason: `mnestraBlock-threw-${e.message}` }; }
220
+ if (typeof block !== 'string') {
221
+ return { skipped: true, reason: 'mnestraBlock-not-string' };
222
+ }
223
+
224
+ let next;
225
+ if (useTomlAppend) {
226
+ next = appendTomlBlock(existing, block);
227
+ } else {
228
+ // useJsonAppend — original brief shape: mnestraBlock returns JSON text,
229
+ // helper deep-merges. Used by adapters that prefer to control the
230
+ // serialization but don't want the escape-hatch's full responsibility.
231
+ let parsed = {};
232
+ if (existing.trim() !== '') {
233
+ try { parsed = JSON.parse(existing); }
234
+ catch (_) { return { skipped: true, reason: 'existing-json-malformed' }; }
235
+ }
236
+ let blockObj;
237
+ try { blockObj = JSON.parse(block); }
238
+ catch (_) { return { skipped: true, reason: 'mnestraBlock-not-parseable-json' }; }
239
+ const merged = mergeJson(parsed, blockObj);
240
+ next = JSON.stringify(merged, null, 2) + '\n';
241
+ }
242
+
243
+ fs.writeFileSync(target, next, { mode: 0o600 });
244
+ return { wrote: true, path: target, bytes: Buffer.byteLength(next) };
245
+ }
246
+
247
+ module.exports = {
248
+ ensureMnestraBlock,
249
+ readSecrets,
250
+ expandTilde,
251
+ // Internals exposed for unit tests; not part of the public API.
252
+ _internals: { mergeJson, appendTomlBlock, jsonAlreadyHasMnestra, SECRETS_PATH },
253
+ };