@jhizzard/termdeck 0.7.2 → 0.8.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.
@@ -11,9 +11,10 @@
11
11
  // or migration failure doesn't lose the user's typed-in keys.
12
12
  // 3. Connect via `pg` using the direct URL
13
13
  // 4. Apply the six bundled Mnestra migrations in order
14
- // 5. Update ~/.termdeck/config.yaml to enable RAG + point at ${VAR} refs
15
- // (only after migrations apply cleanly otherwise the server would
16
- // try to use an incomplete schema on next startup)
14
+ // 5. Update ~/.termdeck/config.yaml set rag.enabled: false (MCP-only
15
+ // default; opt into TermDeck-side RAG via dashboard toggle) and point
16
+ // at ${VAR} refs (only after migrations apply cleanly — otherwise the
17
+ // server would try to use an incomplete schema on next startup)
17
18
  // 6. Verify with a memory_status_aggregation() call
18
19
  //
19
20
  // Flags:
@@ -74,7 +75,8 @@ const HELP = [
74
75
  ' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
75
76
  ' pg connect or migration failure does not lose what you typed in.',
76
77
  ' 3. Connects to Postgres and applies the six Mnestra schema + RPC migrations.',
77
- ' 4. Updates ~/.termdeck/config.yaml to enable RAG and reference ${VAR} keys.',
78
+ ' 4. Updates ~/.termdeck/config.yaml sets rag.enabled: false (MCP-only',
79
+ ' default) and references ${VAR} keys for credentials.',
78
80
  ' 5. Verifies the Mnestra store is reachable via memory_status_aggregation().',
79
81
  '',
80
82
  'Every secret stays on your machine. Nothing is ever printed once entered.',
@@ -180,7 +182,8 @@ This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
180
182
  5. Writing ~/.termdeck/secrets.env (before any database work, so a
181
183
  pg failure cannot lose what you typed in)
182
184
  6. Connecting to Postgres + applying six SQL migrations
183
- 7. Updating ~/.termdeck/config.yaml to enable RAG (only after
185
+ 7. Updating ~/.termdeck/config.yaml rag.enabled: false (MCP-only
186
+ default; toggle in dashboard later) with \${VAR} refs (only after
184
187
  migrations apply cleanly)
185
188
  8. Verifying the connection with a memory_status call
186
189
 
@@ -410,11 +413,27 @@ function writeSecretsFile(inputs, dryRun) {
410
413
  ok();
411
414
  }
412
415
 
416
+ // MCP-only is the default starting v0.7.3. Mnestra's MCP server populates
417
+ // `memory_items` whenever an AI worker calls memory_remember / memory_recall,
418
+ // so the dashboard's Flashback queries work out of the box. The TermDeck-side
419
+ // RAG event tables (mnestra_session_memory / mnestra_project_memory /
420
+ // mnestra_developer_memory / mnestra_commands) stay off until the user opts
421
+ // in via the dashboard or by editing config.yaml. This matches Joshua's
422
+ // daily-driver setup and avoids the v0.7.2-and-earlier asymmetry that hit
423
+ // Brad's box on 2026-04-27 (default `enabled: true` against tables no init
424
+ // path created → 404 cascade → silent RAG drop).
413
425
  function writeYamlConfig(dryRun) {
414
- step('Updating ~/.termdeck/config.yaml (rag.enabled: true)...');
426
+ process.stdout.write(
427
+ '\nSetup mode: MCP-only (default)\n' +
428
+ ' Mnestra MCP tools fill memory_items via memory_remember / memory_recall.\n' +
429
+ ' TermDeck event tables (session / project / developer) stay OFF by default.\n' +
430
+ ' Enable later: toggle in dashboard at http://localhost:3000/#config\n' +
431
+ ' or set rag.enabled: true in ~/.termdeck/config.yaml.\n\n'
432
+ );
433
+ step('Updating ~/.termdeck/config.yaml (rag.enabled: false, MCP-only default)...');
415
434
  if (dryRun) { ok('(dry-run)'); return; }
416
435
  const r = yaml.updateRagConfig({
417
- enabled: true,
436
+ enabled: false,
418
437
  supabaseUrl: '${SUPABASE_URL}',
419
438
  supabaseKey: '${SUPABASE_SERVICE_ROLE_KEY}',
420
439
  openaiApiKey: '${OPENAI_API_KEY}',
@@ -428,6 +447,11 @@ function printNextSteps() {
428
447
  process.stdout.write(`
429
448
  Mnestra is configured.
430
449
 
450
+ Setup mode: MCP-only (default) — TermDeck-side RAG event tables are off.
451
+ To enable session / project / developer memory tables, toggle in the dashboard
452
+ at http://localhost:3000/#config or set rag.enabled: true in
453
+ ~/.termdeck/config.yaml and restart TermDeck.
454
+
431
455
  Next steps:
432
456
  1. Restart TermDeck: termdeck
433
457
  2. Flashback will fire automatically on panel errors
@@ -38,6 +38,12 @@ const {
38
38
  preconditions
39
39
  } = require(SETUP_DIR);
40
40
 
41
+ const {
42
+ CLAUDE_MCP_PATH_CANONICAL,
43
+ readMcpServers,
44
+ writeMcpServers,
45
+ } = require('./mcp-config');
46
+
41
47
  // Pinned fallback used only when the npm registry is unreachable. Bump this
42
48
  // when you republish @jhizzard/rumen and can't (or won't) rely on `npm view`
43
49
  // at deploy time. The wizard prefers the live registry answer — this value
@@ -470,28 +476,29 @@ async function applySchedule(projectRef, secrets, dryRun) {
470
476
  }
471
477
  }
472
478
 
473
- // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json's Supabase MCP
479
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude.json's Supabase MCP
474
480
  // server entry. Background: the meta-installer (`@jhizzard/termdeck-stack`)
475
481
  // writes `SUPABASE_ACCESS_TOKEN: 'SUPABASE_PAT_HERE'` as a literal
476
482
  // placeholder when it wires the Supabase MCP entry. The user is expected
477
483
  // to replace it after install. v0.6.4 unblocked the Rumen install path by
478
484
  // telling users to `export SUPABASE_ACCESS_TOKEN=sbp_...` in their shell —
479
485
  // but that token only got used for `supabase link`, never propagated into
480
- // `~/.claude/mcp.json`. So Brad's Claude Code was talking to a Supabase
481
- // MCP server with a placeholder token. He had to update the JSON file
482
- // manually. Reported 2026-04-26 — Brad's quote: "the token hadn't been
483
- // written to the Json file which we updated manually, but you may want
484
- // to put that in the patch at some point."
486
+ // the MCP config. So Brad's Claude Code was talking to a Supabase MCP
487
+ // server with a placeholder token. Reported 2026-04-26.
488
+ //
489
+ // Sprint 36 T2: default target moved from ~/.claude/mcp.json (legacy) to
490
+ // ~/.claude.json (canonical what Claude Code v2.1.119+ actually reads).
491
+ // Internal write goes through writeMcpServers so the ~55 unrelated
492
+ // top-level keys Claude Code stores in ~/.claude.json (oauthAccount,
493
+ // projects, installMethod, …) are preserved byte-equivalent.
485
494
  //
486
- // This helper closes the loop. Idempotent and conservative:
487
- // - Only runs if process.env.SUPABASE_ACCESS_TOKEN is set
495
+ // Idempotent and conservative:
496
+ // - Only runs if a token is provided via env or arg
488
497
  // - Only updates when the existing value is the literal placeholder
489
498
  // 'SUPABASE_PAT_HERE' — preserves any real token the user already set
490
- // - No-op when ~/.claude/mcp.json doesn't exist (user never ran the
491
- // meta-installer's Tier 4) or when there's no `supabase` MCP entry
499
+ // - No-op when the file doesn't exist or has no `supabase` entry
492
500
  // - No-op (with a soft warning) when the JSON is malformed
493
- // - Atomic write via tmp-and-rename; mode 0600 to match the file's
494
- // existing permissions (it already holds the placeholder)
501
+ // - Atomic write via tmp-and-rename; mode 0600
495
502
  // - All other mcpServers entries preserved verbatim
496
503
  //
497
504
  // Returns one of: { status: 'updated', path }, { status: 'already-set', path },
@@ -502,24 +509,15 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
502
509
  const tokenValue = token || process.env.SUPABASE_ACCESS_TOKEN;
503
510
  if (!tokenValue) return { status: 'no-token-in-env' };
504
511
 
505
- const targetPath = mcpJsonPath || path.join(os.homedir(), '.claude', 'mcp.json');
512
+ const targetPath = mcpJsonPath || CLAUDE_MCP_PATH_CANONICAL;
506
513
  if (!fsImpl.existsSync(targetPath)) return { status: 'no-file' };
507
514
 
508
- let raw;
509
- try {
510
- raw = fsImpl.readFileSync(targetPath, 'utf-8');
511
- } catch (err) {
512
- return { status: 'malformed', path: targetPath, error: err.message };
513
- }
514
-
515
- let cfg;
516
- try {
517
- cfg = JSON.parse(raw);
518
- } catch (err) {
519
- return { status: 'malformed', path: targetPath, error: err.message };
515
+ const read = readMcpServers(targetPath);
516
+ if (read.malformed) {
517
+ return { status: 'malformed', path: targetPath, error: read.error };
520
518
  }
521
519
 
522
- const supabaseEntry = cfg && cfg.mcpServers && cfg.mcpServers.supabase;
520
+ const supabaseEntry = read.servers && read.servers.supabase;
523
521
  if (!supabaseEntry || typeof supabaseEntry !== 'object') {
524
522
  return { status: 'no-supabase-entry', path: targetPath };
525
523
  }
@@ -528,16 +526,15 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
528
526
  const current = supabaseEntry.env.SUPABASE_ACCESS_TOKEN;
529
527
  if (current === tokenValue) return { status: 'already-set', path: targetPath };
530
528
  if (current && current !== 'SUPABASE_PAT_HERE') {
531
- // User has set a real token already — don't touch it.
532
529
  return { status: 'already-set', path: targetPath };
533
530
  }
534
531
 
535
532
  supabaseEntry.env.SUPABASE_ACCESS_TOKEN = tokenValue;
536
533
 
537
- const tmpPath = `${targetPath}.tmp.${process.pid}`;
538
- fsImpl.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
539
- fsImpl.renameSync(tmpPath, targetPath);
540
- try { fsImpl.chmodSync(targetPath, 0o600); } catch (_e) { /* best-effort */ }
534
+ // writeMcpServers re-reads `targetPath` to preserve every top-level key
535
+ // (oauthAccount, projects, installMethod, ) that Claude Code owns. Only
536
+ // .mcpServers gets replaced with our mutated map.
537
+ writeMcpServers(targetPath, read.servers);
541
538
 
542
539
  return { status: 'updated', path: targetPath };
543
540
  }
@@ -608,14 +605,14 @@ async function main(argv) {
608
605
 
609
606
  if (!(await link(projectRef, flags.dryRun))) return 4;
610
607
 
611
- // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json now that
608
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude.json now that
612
609
  // `supabase link` succeeded (the token is verified-real). The
613
610
  // meta-installer wrote a literal 'SUPABASE_PAT_HERE' placeholder
614
611
  // there during Tier 4 install — this closes that loop.
615
612
  if (!flags.dryRun) {
616
613
  const r = wireAccessTokenInMcpJson();
617
614
  if (r.status === 'updated') {
618
- step('Backfilled SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json...');
615
+ step(`Backfilled SUPABASE_ACCESS_TOKEN into ${r.path}...`);
619
616
  ok();
620
617
  } else if (r.status === 'malformed') {
621
618
  process.stderr.write(
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ // Canonical schema/CRUD for the Claude Code MCP server config.
4
+ //
5
+ // Sprint 36 T2: Claude Code v2.1.119+ reads its MCP config from
6
+ // ~/.claude.json (top-level `mcpServers` key, alongside ~55 other internal
7
+ // keys it owns). Earlier installs wrote to ~/.claude/mcp.json, which the
8
+ // current Claude Code never reads. Fresh users hit this as "the install is
9
+ // broken" — Mnestra was wired into the wrong file.
10
+ //
11
+ // This module is the single source of truth for path constants and the
12
+ // read-modify-write helpers all installer/CLI code paths use. Two physical
13
+ // copies exist (this one + packages/stack-installer/src/mcp-config.js) so
14
+ // each published npm package stays self-contained. The stack-installer
15
+ // copy must stay in sync with this one — same exports, same semantics.
16
+
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ const CLAUDE_MCP_PATH_CANONICAL = path.join(os.homedir(), '.claude.json');
22
+ const CLAUDE_MCP_PATH_LEGACY = path.join(os.homedir(), '.claude', 'mcp.json');
23
+
24
+ // readMcpServers(filePath) → { servers, raw, missing, malformed, error }
25
+ //
26
+ // servers the .mcpServers map (always an object, never undefined)
27
+ // raw the full parsed top-level object (for structure-preserving
28
+ // write-back). Empty object on missing/malformed.
29
+ // missing true when the file doesn't exist
30
+ // malformed true when the file exists but JSON.parse failed
31
+ // error parse error message when malformed
32
+ function readMcpServers(filePath) {
33
+ if (!fs.existsSync(filePath)) {
34
+ return { servers: {}, raw: {}, missing: true, malformed: false };
35
+ }
36
+ let text;
37
+ try {
38
+ text = fs.readFileSync(filePath, 'utf8');
39
+ } catch (err) {
40
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
41
+ }
42
+ if (text.trim() === '') {
43
+ return { servers: {}, raw: {}, missing: false, malformed: false };
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(text);
48
+ } catch (err) {
49
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
50
+ }
51
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
52
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: 'top-level must be an object' };
53
+ }
54
+ const servers = (parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers))
55
+ ? parsed.mcpServers
56
+ : {};
57
+ return { servers, raw: parsed, missing: false, malformed: false };
58
+ }
59
+
60
+ // mergeMcpServers(currentServers, legacyServers) → merged map
61
+ //
62
+ // Current wins on key collision — current is the source of truth, legacy
63
+ // is a migration source. Both inputs are tolerated as null/undefined.
64
+ function mergeMcpServers(currentServers, legacyServers) {
65
+ const out = {};
66
+ const legacy = (legacyServers && typeof legacyServers === 'object') ? legacyServers : {};
67
+ const current = (currentServers && typeof currentServers === 'object') ? currentServers : {};
68
+ for (const [name, entry] of Object.entries(legacy)) {
69
+ out[name] = entry;
70
+ }
71
+ for (const [name, entry] of Object.entries(current)) {
72
+ out[name] = entry;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // writeMcpServers(filePath, servers) — atomic, structure-preserving.
78
+ //
79
+ // If the file exists with other top-level keys (the common case for
80
+ // ~/.claude.json), only `.mcpServers` is replaced; everything else
81
+ // survives byte-equivalent through JSON.parse → JSON.stringify. If the
82
+ // file is missing or empty, writes a minimal `{ mcpServers: {...} }`.
83
+ // Atomic via tmp-and-rename. Mode 0600.
84
+ function writeMcpServers(filePath, servers) {
85
+ const existing = readMcpServers(filePath);
86
+ const next = (existing.malformed || existing.missing)
87
+ ? {}
88
+ : { ...existing.raw };
89
+ next.mcpServers = servers || {};
90
+
91
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
92
+ const tmp = `${filePath}.tmp.${process.pid}`;
93
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
94
+ fs.renameSync(tmp, filePath);
95
+ try { fs.chmodSync(filePath, 0o600); } catch (_e) { /* best-effort */ }
96
+ }
97
+
98
+ // migrateLegacyIfPresent({ dryRun, canonicalPath, legacyPath })
99
+ // → { migrated, kept, wrote, canonicalPath, legacyPath, malformed }
100
+ //
101
+ // migrated array of server names copied from legacy → canonical
102
+ // kept array of names that existed in both (canonical wins,
103
+ // legacy version skipped)
104
+ // wrote true if the canonical file was written
105
+ // malformed { canonical?: error, legacy?: error } when either parse failed
106
+ //
107
+ // Idempotent: a second invocation with no new legacy entries returns
108
+ // migrated: []. Never deletes or modifies the legacy file.
109
+ function migrateLegacyIfPresent(opts = {}) {
110
+ const dryRun = !!opts.dryRun;
111
+ const canonicalPath = opts.canonicalPath || CLAUDE_MCP_PATH_CANONICAL;
112
+ const legacyPath = opts.legacyPath || CLAUDE_MCP_PATH_LEGACY;
113
+
114
+ const canonical = readMcpServers(canonicalPath);
115
+ const legacy = readMcpServers(legacyPath);
116
+
117
+ const malformed = {};
118
+ if (canonical.malformed) malformed.canonical = canonical.error || true;
119
+ if (legacy.malformed) malformed.legacy = legacy.error || true;
120
+
121
+ if (legacy.missing || legacy.malformed) {
122
+ return {
123
+ migrated: [],
124
+ kept: [],
125
+ wrote: false,
126
+ canonicalPath,
127
+ legacyPath,
128
+ malformed: Object.keys(malformed).length ? malformed : undefined,
129
+ };
130
+ }
131
+
132
+ const migrated = [];
133
+ const kept = [];
134
+ const merged = { ...canonical.servers };
135
+ for (const [name, entry] of Object.entries(legacy.servers)) {
136
+ if (Object.prototype.hasOwnProperty.call(canonical.servers, name)) {
137
+ kept.push(name);
138
+ } else {
139
+ merged[name] = entry;
140
+ migrated.push(name);
141
+ }
142
+ }
143
+
144
+ if (migrated.length === 0) {
145
+ return {
146
+ migrated: [],
147
+ kept,
148
+ wrote: false,
149
+ canonicalPath,
150
+ legacyPath,
151
+ malformed: Object.keys(malformed).length ? malformed : undefined,
152
+ };
153
+ }
154
+
155
+ if (!dryRun) writeMcpServers(canonicalPath, merged);
156
+
157
+ return {
158
+ migrated,
159
+ kept,
160
+ wrote: !dryRun,
161
+ canonicalPath,
162
+ legacyPath,
163
+ malformed: Object.keys(malformed).length ? malformed : undefined,
164
+ };
165
+ }
166
+
167
+ module.exports = {
168
+ CLAUDE_MCP_PATH_CANONICAL,
169
+ CLAUDE_MCP_PATH_LEGACY,
170
+ readMcpServers,
171
+ mergeMcpServers,
172
+ writeMcpServers,
173
+ migrateLegacyIfPresent,
174
+ };
@@ -22,6 +22,16 @@ const SECRETS_FILE = path.join(CONFIG_DIR, 'secrets.env');
22
22
  const DEFAULT_MNESTRA_PORT = parseInt(process.env.MNESTRA_PORT || '37778', 10);
23
23
  const MNESTRA_LOG = path.join(os.tmpdir(), 'termdeck-mnestra.log');
24
24
 
25
+ // Sprint 36: Claude Code v2.1.119+ reads MCP servers from ~/.claude.json
26
+ // (canonical). The legacy ~/.claude/mcp.json is still accepted by older
27
+ // versions. Detection checks BOTH; T2 migrates writes to the canonical path.
28
+ // Exported so T2 (init-rumen, stack-installer, supabase-mcp) and any other
29
+ // caller stays in sync — single source of truth for "where does Claude Code
30
+ // look for MCP entries today".
31
+ const CLAUDE_MCP_PATH_CANONICAL = path.join(HOME, '.claude.json');
32
+ const CLAUDE_MCP_PATH_LEGACY = path.join(HOME, '.claude', 'mcp.json');
33
+ const CLAUDE_MCP_PATHS = [CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY];
34
+
25
35
  const ANSI = {
26
36
  green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
27
37
  dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
@@ -51,6 +61,23 @@ function subNote(msg) {
51
61
  process.stdout.write(` ${ANSI.dim}└ ${msg}${ANSI.reset}\n`);
52
62
  }
53
63
 
64
+ // Sprint 36: scan both Claude Code MCP config paths for a Mnestra entry.
65
+ // Returns true if either file parses and contains the substring "mnestra"
66
+ // anywhere in its JSON (covers top-level mcpServers.mnestra AND per-project
67
+ // blocks). Malformed JSON or missing files count as "no entry" — the hint
68
+ // will fire and tell the user to run the installer, which is the desired
69
+ // recovery for both states.
70
+ function hasMnestraMcpEntry() {
71
+ for (const p of CLAUDE_MCP_PATHS) {
72
+ if (!fs.existsSync(p)) continue;
73
+ try {
74
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
75
+ if (JSON.stringify(j).includes('mnestra')) return true;
76
+ } catch (_e) { /* malformed — skip, treat as missing */ }
77
+ }
78
+ return false;
79
+ }
80
+
54
81
  // ── Args ─────────────────────────────────────────────────────────────
55
82
 
56
83
  function parseArgs(argv) {
@@ -181,11 +208,32 @@ function isPidTermDeck(pid) {
181
208
  return /packages\/cli\/src\/index\.js|termdeck/.test(r.stdout || '');
182
209
  }
183
210
 
211
+ // Liveness probe — a TermDeck that answers /api/sessions with a JSON array is
212
+ // not stale; it's the orchestrator's live server, and killing it cascades to
213
+ // every child PTY. On 2026-04-27 this caused two Sprint 36 server-kill
214
+ // incidents (lane workers triggering reclaimPort against the live :3000).
215
+ async function isTermDeckLive(port) {
216
+ try {
217
+ const j = await httpJson(`http://localhost:${port}/api/sessions`, 1500);
218
+ return Array.isArray(j);
219
+ } catch (_e) {
220
+ return false;
221
+ }
222
+ }
223
+
184
224
  async function reclaimPort(port) {
185
225
  const pids = lsofPids(port);
186
226
  if (pids.length === 0) return { reclaimed: false, blockerPids: [] };
187
227
  const termdeckPids = pids.filter(isPidTermDeck);
188
228
  if (termdeckPids.length === 0) return { reclaimed: false, blockerPids: pids };
229
+
230
+ // Self-recognition guard: never kill a responsive TermDeck. Use --port to
231
+ // start a second instance instead.
232
+ if (await isTermDeckLive(port)) {
233
+ subNote(`TermDeck on port ${port} is live (PIDs: ${termdeckPids.join(' ')}) — not killing. Use --port <other> to start a second instance.`);
234
+ return { reclaimed: false, blockerPids: termdeckPids, alreadyLive: true };
235
+ }
236
+
189
237
  for (const pid of termdeckPids) {
190
238
  try { process.kill(pid, 'SIGTERM'); } catch (_e) { /* already dead */ }
191
239
  }
@@ -449,17 +497,11 @@ async function main(rawArgs) {
449
497
 
450
498
  const mnestra = await startMnestra({ skip: args.noMnestra });
451
499
 
452
- // MCP config hint
453
- if (mnestra.active) {
454
- const mcpPath = path.join(HOME, '.claude', 'mcp.json');
455
- let needsHint = !fs.existsSync(mcpPath);
456
- if (!needsHint) {
457
- try {
458
- const j = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
459
- if (!JSON.stringify(j).includes('mnestra')) needsHint = true;
460
- } catch (_e) { needsHint = true; }
461
- }
462
- if (needsHint) subNote(`Hint: add a 'mnestra' entry to ~/.claude/mcp.json for Claude Code`);
500
+ // Sprint 36: MCP-absence hint. Claude Code v2.1.119+ reads from
501
+ // ~/.claude.json; legacy versions read ~/.claude/mcp.json. Mnestra is
502
+ // "wired" if EITHER file mentions it — otherwise the hint fires.
503
+ if (mnestra.active && !hasMnestraMcpEntry()) {
504
+ subNote(`TermDeck doesn't see Mnestra wired in Claude Code yet. Run: npx @jhizzard/termdeck-stack`);
463
505
  }
464
506
 
465
507
  const rumen = await checkRumen();
@@ -482,3 +524,11 @@ module.exports = function (argv) {
482
524
  return 1;
483
525
  });
484
526
  };
527
+
528
+ // Sprint 36: shared MCP-config path constants. Other CLI/installer modules
529
+ // (T2's lane: init-rumen.js, stack-installer, supabase-mcp.js) import from
530
+ // here so the canonical-vs-legacy decision lives in exactly one file.
531
+ module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
532
+ module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
533
+ module.exports.CLAUDE_MCP_PATHS = CLAUDE_MCP_PATHS;
534
+ module.exports.hasMnestraMcpEntry = hasMnestraMcpEntry;
@@ -25,6 +25,7 @@
25
25
  async function init() {
26
26
  // Load config
27
27
  state.config = await api('GET', '/api/config');
28
+ updateRagIndicator();
28
29
 
29
30
  // Populate project dropdown
30
31
  const sel = document.getElementById('promptProject');
@@ -247,6 +248,16 @@
247
248
  case 'status_broadcast':
248
249
  updateGlobalStats(msg.sessions);
249
250
  break;
251
+ case 'config_changed':
252
+ // Sprint 36 T3 Deliverable A: server-broadcast on PATCH /api/config.
253
+ // Each open panel WebSocket receives one copy; the handler is
254
+ // idempotent so multiple receipts settle the same state.
255
+ if (msg.config) {
256
+ state.config = { ...state.config, ...msg.config };
257
+ if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
258
+ if (typeof updateRagIndicator === 'function') updateRagIndicator();
259
+ }
260
+ break;
250
261
  }
251
262
  } catch (err) { console.error('[client] ws message parse failed:', err); }
252
263
  };
@@ -2299,8 +2310,17 @@
2299
2310
  // Explicitly show the spotlight. CSS default is `display:none` so the
2300
2311
  // 9999px box-shadow doesn't darken the page before/after a tour runs.
2301
2312
  document.getElementById('tourSpotlight').style.display = 'block';
2302
- await ensurePanelForTour();
2303
- renderTourStep();
2313
+ // Defensive cleanup: if ensurePanelForTour or renderTourStep throws
2314
+ // after the spotlight is shown, the 9999px box-shadow stays up with no
2315
+ // tooltip on top — that's the "dark veil" symptom users hit with no
2316
+ // visible way out. Roll back to a clean state on any failure.
2317
+ try {
2318
+ await ensurePanelForTour();
2319
+ renderTourStep();
2320
+ } catch (err) {
2321
+ console.error('[tour] start failed, rolling back:', err);
2322
+ endTour();
2323
+ }
2304
2324
  }
2305
2325
 
2306
2326
  function nextTourStep() {
@@ -2523,6 +2543,7 @@
2523
2543
  <button type="button" class="setup-close" id="setupClose" aria-label="Close">×</button>
2524
2544
  </header>
2525
2545
  <div class="setup-body">
2546
+ <div class="setup-settings" id="setupSettings"></div>
2526
2547
  <div class="setup-tiers" id="setupTiers">
2527
2548
  <div class="setup-loading">Checking tier status…</div>
2528
2549
  </div>
@@ -2553,6 +2574,7 @@
2553
2574
  ensureSetupModal();
2554
2575
  document.getElementById('setupModal').classList.add('open');
2555
2576
  setupModalOpen = true;
2577
+ renderSettingsPanel();
2556
2578
  await refreshSetupStatus();
2557
2579
  }
2558
2580
 
@@ -2562,6 +2584,96 @@
2562
2584
  setupModalOpen = false;
2563
2585
  }
2564
2586
 
2587
+ // ===== Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) =====
2588
+ // Renders the writable subset of /api/config — currently just the RAG toggle.
2589
+ // Body is mutated in place; the panel is idempotent so config_changed WS
2590
+ // events can call it without reflow flicker.
2591
+ function renderSettingsPanel() {
2592
+ const el = document.getElementById('setupSettings');
2593
+ if (!el) return;
2594
+ const cfg = state.config || {};
2595
+ const intent = !!cfg.ragConfigEnabled;
2596
+ const effective = !!cfg.ragEnabled;
2597
+ const supabaseConfigured = !!cfg.ragSupabaseConfigured;
2598
+
2599
+ // Mismatch: user enabled RAG in config but Supabase isn't wired → show
2600
+ // a hint so the toggle's "ON but not pushing" state is explainable.
2601
+ const mismatch = intent && !effective && !supabaseConfigured;
2602
+
2603
+ const offCopy = 'MCP-only mode. Memory tools available through Claude Code; the in-CLI <code>termdeck flashback</code> command and the hybrid search are disabled. Faster boot, slimmer surface.';
2604
+ const onCopy = 'Enables <code>termdeck flashback</code> and the in-CLI hybrid search. Requires a Mnestra connection at boot — adds a few hundred ms to startup.';
2605
+
2606
+ el.innerHTML = `
2607
+ <div class="settings-section">
2608
+ <h4 class="settings-heading">RAG mode</h4>
2609
+ <div class="settings-row">
2610
+ <label class="toggle" for="settingsRagToggle">
2611
+ <input type="checkbox" id="settingsRagToggle" ${intent ? 'checked' : ''}>
2612
+ <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
2613
+ <span class="toggle-label">${intent ? 'On' : 'Off'}</span>
2614
+ </label>
2615
+ <p class="settings-copy">${intent ? onCopy : offCopy}</p>
2616
+ </div>
2617
+ ${mismatch ? `
2618
+ <div class="settings-warn">
2619
+ RAG is enabled in <code>config.yaml</code> but Supabase isn't configured yet, so it isn't actually pushing.
2620
+ Configure Tier 2 below or run <code>npx @jhizzard/termdeck-stack</code>.
2621
+ </div>
2622
+ ` : ''}
2623
+ </div>
2624
+ `;
2625
+
2626
+ const toggle = document.getElementById('settingsRagToggle');
2627
+ if (toggle) {
2628
+ toggle.addEventListener('change', async (e) => {
2629
+ const desired = !!e.target.checked;
2630
+ // Optimistic UI: lock the toggle while the round-trip is in flight.
2631
+ toggle.disabled = true;
2632
+ try {
2633
+ const updated = await api('PATCH', '/api/config', { rag: { enabled: desired } });
2634
+ state.config = { ...state.config, ...updated };
2635
+ renderSettingsPanel();
2636
+ updateRagIndicator();
2637
+ } catch (err) {
2638
+ console.error('[settings] PATCH /api/config failed:', err);
2639
+ // Revert: refetch and re-render.
2640
+ try {
2641
+ state.config = await api('GET', '/api/config');
2642
+ renderSettingsPanel();
2643
+ } catch {}
2644
+ } finally {
2645
+ const t = document.getElementById('settingsRagToggle');
2646
+ if (t) t.disabled = false;
2647
+ }
2648
+ });
2649
+ }
2650
+ }
2651
+
2652
+ // Topbar RAG indicator. The #stat-rag stub in index.html was hidden by
2653
+ // Sprint 9 T2; re-purpose it as a live state line so users can see, at a
2654
+ // glance, what the toggle is doing without opening Settings each time.
2655
+ function updateRagIndicator() {
2656
+ const el = document.getElementById('stat-rag');
2657
+ if (!el) return;
2658
+ const cfg = state.config || {};
2659
+ const intent = !!cfg.ragConfigEnabled;
2660
+ const effective = !!cfg.ragEnabled;
2661
+ el.style.display = '';
2662
+ if (effective) {
2663
+ el.textContent = 'RAG · on';
2664
+ el.className = 'topbar-stat rag-on';
2665
+ el.title = 'Mnestra hybrid search + termdeck flashback enabled';
2666
+ } else if (intent) {
2667
+ el.textContent = 'RAG · pending';
2668
+ el.className = 'topbar-stat rag-pending';
2669
+ el.title = 'RAG enabled in config.yaml but Supabase not wired — see Settings';
2670
+ } else {
2671
+ el.textContent = 'RAG · mcp-only';
2672
+ el.className = 'topbar-stat rag-off';
2673
+ el.title = 'MCP-only mode; toggle in Settings to enable';
2674
+ }
2675
+ }
2676
+
2565
2677
  async function refreshSetupStatus() {
2566
2678
  const tiersEl = document.getElementById('setupTiers');
2567
2679
  const subtitle = document.getElementById('setupSubtitle');