@jhizzard/termdeck 0.7.3 → 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.
- package/package.json +1 -1
- package/packages/cli/src/auto-orchestrate.js +28 -22
- package/packages/cli/src/index.js +29 -8
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- package/packages/client/public/app.js +114 -2
- package/packages/client/public/style.css +121 -0
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +61 -5
- package/packages/server/src/rag.js +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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"
|
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
// Detection helper for
|
|
2
|
-
//
|
|
3
|
-
// the dispatcher in index.js still owns the actual
|
|
1
|
+
// Detection helper for default-entry routing — does plain `termdeck`
|
|
2
|
+
// (no subcommand) route through stack.js? Pure function, isolated for
|
|
3
|
+
// testability — the dispatcher in index.js still owns the actual
|
|
4
|
+
// routing decision.
|
|
5
|
+
//
|
|
6
|
+
// Sprint 24 policy (original): only auto-orchestrate when both
|
|
7
|
+
// ~/.termdeck/secrets.env and ~/.termdeck/config.yaml exist AND either
|
|
8
|
+
// mnestra.autoStart or rag.enabled is true. Fresh boxes fell through to
|
|
9
|
+
// Tier-1-only.
|
|
10
|
+
//
|
|
11
|
+
// Sprint 36 policy (current): always orchestrate by default. Acceptance
|
|
12
|
+
// criterion #2 of Sprint 36 (`docs/sprint-36-launcher-ui-parity/PLANNING.md`)
|
|
13
|
+
// requires `npx @jhizzard/termdeck` to match `scripts/start.sh` step-by-step
|
|
14
|
+
// on every machine, fresh or configured. stack.js handles the fresh-machine
|
|
15
|
+
// case via `ensureFirstRunConfig()` — it auto-writes a minimal
|
|
16
|
+
// ~/.termdeck/config.yaml on first run, then proceeds through Step 1/4–4/4
|
|
17
|
+
// with mostly-SKIP statuses (no secrets → SKIP, no mnestra binary → SKIP,
|
|
18
|
+
// no DATABASE_URL → SKIP, BOOT). That output mirrors what start.sh produces
|
|
19
|
+
// on a fresh box.
|
|
20
|
+
//
|
|
21
|
+
// The escape hatch is the explicit `--no-stack` flag handled in index.js.
|
|
22
|
+
//
|
|
23
|
+
// The function signature stays the same so callers and tests don't break;
|
|
24
|
+
// the body is just a constant now. Keeping the function (rather than
|
|
25
|
+
// inlining the boolean) leaves a hook for future telemetry — e.g., emitting
|
|
26
|
+
// "why we orchestrated" reasons — without another dispatcher rewrite.
|
|
4
27
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
function shouldAutoOrchestrate(homeDir) {
|
|
10
|
-
const home = homeDir || os.homedir();
|
|
11
|
-
const secretsPath = path.join(home, '.termdeck', 'secrets.env');
|
|
12
|
-
const configPath = path.join(home, '.termdeck', 'config.yaml');
|
|
13
|
-
if (!fs.existsSync(secretsPath) || !fs.existsSync(configPath)) return false;
|
|
14
|
-
let parsed;
|
|
15
|
-
try {
|
|
16
|
-
const yaml = require('yaml');
|
|
17
|
-
parsed = yaml.parse(fs.readFileSync(configPath, 'utf8')) || {};
|
|
18
|
-
} catch (_e) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
const mnestraAuto = parsed.mnestra && parsed.mnestra.autoStart === true;
|
|
22
|
-
const ragEnabled = parsed.rag && parsed.rag.enabled === true;
|
|
23
|
-
return Boolean(mnestraAuto || ragEnabled);
|
|
28
|
+
function shouldAutoOrchestrate(_homeDir) {
|
|
29
|
+
return true;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
module.exports = { shouldAutoOrchestrate };
|
|
@@ -52,6 +52,23 @@ function reclaimStalePort(port) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if (isTermDeck) {
|
|
55
|
+
// Liveness probe — never kill a TermDeck that's actively serving requests.
|
|
56
|
+
// A responsive /api/sessions means it's the orchestrator's live server, and
|
|
57
|
+
// killing it cascades to every child PTY. This was the actual root cause of
|
|
58
|
+
// four Sprint 36 server-kill incidents on 2026-04-27 (a sibling reclaimPort
|
|
59
|
+
// in stack.js had the same flaw and was already patched; this twin in the
|
|
60
|
+
// CLI entry was missed). Mirror of stack.js:isTermDeckLive.
|
|
61
|
+
let alreadyLive = false;
|
|
62
|
+
try {
|
|
63
|
+
const probe = execSync(`curl -sf -m 1.5 -o /dev/null -w "%{http_code}" http://127.0.0.1:${port}/api/sessions 2>/dev/null`, { encoding: 'utf8' });
|
|
64
|
+
if (probe.trim() === '200') alreadyLive = true;
|
|
65
|
+
} catch (_e) { /* curl missing or non-200 → treat as stale */ }
|
|
66
|
+
|
|
67
|
+
if (alreadyLive) {
|
|
68
|
+
console.log(` \x1b[2m[port] :${port} held by live TermDeck (PIDs: ${pids.join(' ')}) — not killing. Use --port <other> for a second instance.\x1b[0m`);
|
|
69
|
+
process.exit(0); // graceful exit; don't try to bind a port that's already serving
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
console.log(` \x1b[2m[port] Reclaiming :${port} from stale TermDeck (PIDs: ${pids.join(' ')})\x1b[0m`);
|
|
56
73
|
for (const pid of pids) {
|
|
57
74
|
try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch (_e) {}
|
|
@@ -232,6 +249,7 @@ const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yam
|
|
|
232
249
|
|
|
233
250
|
const config = loadConfig();
|
|
234
251
|
if (flags.port) config.port = flags.port;
|
|
252
|
+
else if (process.env.TERMDECK_PORT) config.port = parseInt(process.env.TERMDECK_PORT, 10);
|
|
235
253
|
if (flags.sessionLogs) {
|
|
236
254
|
config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
|
|
237
255
|
console.log('[cli] session logs enabled — writing to ~/.termdeck/sessions/ on panel exit');
|
|
@@ -262,17 +280,20 @@ if (!LOOPBACK.has(host)) {
|
|
|
262
280
|
// Sprint 25 T4: non-blocking nudge when RAG is configured but the Supabase MCP
|
|
263
281
|
// (T1's `@supabase/mcp-server-supabase` detection) isn't installed. Lazy-loads
|
|
264
282
|
// T1's module so Tier 1 users with no RAG never pay the require cost. Silent
|
|
265
|
-
// when RAG is off, when the MCP is detected, when
|
|
266
|
-
// declares a `supabase`
|
|
283
|
+
// when RAG is off, when the MCP is detected, when the MCP config (canonical
|
|
284
|
+
// ~/.claude.json or legacy ~/.claude/mcp.json) already declares a `supabase`
|
|
285
|
+
// server, or when anything below throws.
|
|
286
|
+
//
|
|
287
|
+
// Sprint 36 T2: read order is canonical → legacy. Claude Code v2.1.119+ reads
|
|
288
|
+
// only the canonical file; the legacy fallback covers users who haven't yet
|
|
289
|
+
// migrated and pinned other tooling to the old path.
|
|
267
290
|
async function checkSupabaseMcpHint(cfg) {
|
|
268
291
|
if (!cfg || !cfg.rag || cfg.rag.enabled !== true) return null;
|
|
269
292
|
try {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (parsed && parsed.mcpServers && parsed.mcpServers.supabase) return null;
|
|
275
|
-
} catch (_e) { /* malformed JSON — fall through and let detectMcp decide */ }
|
|
293
|
+
const { CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY, readMcpServers } = require('./mcp-config');
|
|
294
|
+
for (const candidate of [CLAUDE_MCP_PATH_CANONICAL, CLAUDE_MCP_PATH_LEGACY]) {
|
|
295
|
+
const read = readMcpServers(candidate);
|
|
296
|
+
if (read.servers && read.servers.supabase) return null;
|
|
276
297
|
}
|
|
277
298
|
const { detectMcp } = require(path.join(__dirname, '..', '..', 'server', 'src', 'setup', 'supabase-mcp.js'));
|
|
278
299
|
const result = await detectMcp();
|
|
@@ -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
|
|
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
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
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
|
-
//
|
|
487
|
-
// - Only runs if
|
|
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
|
|
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
|
|
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 ||
|
|
512
|
+
const targetPath = mcpJsonPath || CLAUDE_MCP_PATH_CANONICAL;
|
|
506
513
|
if (!fsImpl.existsSync(targetPath)) return { status: 'no-file' };
|
|
507
514
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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 =
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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(
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
2303
|
-
|
|
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');
|
|
@@ -2002,6 +2002,127 @@
|
|
|
2002
2002
|
justify-content: center;
|
|
2003
2003
|
}
|
|
2004
2004
|
.setup-modal.open { display: flex; }
|
|
2005
|
+
|
|
2006
|
+
/* Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) */
|
|
2007
|
+
.setup-settings {
|
|
2008
|
+
padding: 0 0 16px;
|
|
2009
|
+
margin-bottom: 14px;
|
|
2010
|
+
border-bottom: 1px solid var(--tg-border);
|
|
2011
|
+
}
|
|
2012
|
+
.settings-section {
|
|
2013
|
+
display: flex;
|
|
2014
|
+
flex-direction: column;
|
|
2015
|
+
gap: 6px;
|
|
2016
|
+
}
|
|
2017
|
+
.settings-heading {
|
|
2018
|
+
margin: 0;
|
|
2019
|
+
font-size: 12px;
|
|
2020
|
+
letter-spacing: 0.06em;
|
|
2021
|
+
text-transform: uppercase;
|
|
2022
|
+
color: var(--tg-text-dim);
|
|
2023
|
+
font-family: var(--tg-sans);
|
|
2024
|
+
}
|
|
2025
|
+
.settings-row {
|
|
2026
|
+
display: grid;
|
|
2027
|
+
grid-template-columns: 120px 1fr;
|
|
2028
|
+
gap: 14px;
|
|
2029
|
+
align-items: start;
|
|
2030
|
+
}
|
|
2031
|
+
.settings-copy {
|
|
2032
|
+
margin: 0;
|
|
2033
|
+
font-size: 12px;
|
|
2034
|
+
line-height: 1.45;
|
|
2035
|
+
color: var(--tg-text);
|
|
2036
|
+
}
|
|
2037
|
+
.settings-copy code {
|
|
2038
|
+
font-family: var(--tg-mono);
|
|
2039
|
+
font-size: 11px;
|
|
2040
|
+
background: var(--tg-bg);
|
|
2041
|
+
padding: 1px 5px;
|
|
2042
|
+
border-radius: 3px;
|
|
2043
|
+
}
|
|
2044
|
+
.settings-warn {
|
|
2045
|
+
margin-top: 4px;
|
|
2046
|
+
padding: 8px 10px;
|
|
2047
|
+
font-size: 12px;
|
|
2048
|
+
line-height: 1.4;
|
|
2049
|
+
color: var(--tg-text);
|
|
2050
|
+
background: rgba(224, 175, 104, 0.10);
|
|
2051
|
+
border: 1px solid var(--tg-amber);
|
|
2052
|
+
border-radius: 4px;
|
|
2053
|
+
}
|
|
2054
|
+
.settings-warn code {
|
|
2055
|
+
font-family: var(--tg-mono);
|
|
2056
|
+
font-size: 11px;
|
|
2057
|
+
background: var(--tg-bg);
|
|
2058
|
+
padding: 1px 5px;
|
|
2059
|
+
border-radius: 3px;
|
|
2060
|
+
}
|
|
2061
|
+
/* iOS-style toggle. The native checkbox is visually hidden but stays in
|
|
2062
|
+
the tab order so keyboard + screen reader navigation still work. */
|
|
2063
|
+
.toggle {
|
|
2064
|
+
display: inline-flex;
|
|
2065
|
+
align-items: center;
|
|
2066
|
+
gap: 8px;
|
|
2067
|
+
cursor: pointer;
|
|
2068
|
+
user-select: none;
|
|
2069
|
+
}
|
|
2070
|
+
.toggle input[type="checkbox"] {
|
|
2071
|
+
position: absolute;
|
|
2072
|
+
opacity: 0;
|
|
2073
|
+
width: 1px;
|
|
2074
|
+
height: 1px;
|
|
2075
|
+
pointer-events: none;
|
|
2076
|
+
}
|
|
2077
|
+
.toggle-track {
|
|
2078
|
+
position: relative;
|
|
2079
|
+
display: inline-block;
|
|
2080
|
+
width: 36px;
|
|
2081
|
+
height: 20px;
|
|
2082
|
+
background: var(--tg-border);
|
|
2083
|
+
border-radius: 999px;
|
|
2084
|
+
transition: background 0.15s ease;
|
|
2085
|
+
}
|
|
2086
|
+
.toggle-thumb {
|
|
2087
|
+
position: absolute;
|
|
2088
|
+
top: 2px;
|
|
2089
|
+
left: 2px;
|
|
2090
|
+
width: 16px;
|
|
2091
|
+
height: 16px;
|
|
2092
|
+
background: var(--tg-text-bright);
|
|
2093
|
+
border-radius: 50%;
|
|
2094
|
+
transition: transform 0.15s ease, background 0.15s ease;
|
|
2095
|
+
}
|
|
2096
|
+
.toggle input[type="checkbox"]:checked + .toggle-track {
|
|
2097
|
+
background: var(--tg-green);
|
|
2098
|
+
}
|
|
2099
|
+
.toggle input[type="checkbox"]:checked + .toggle-track .toggle-thumb {
|
|
2100
|
+
transform: translateX(16px);
|
|
2101
|
+
background: var(--tg-bg);
|
|
2102
|
+
}
|
|
2103
|
+
.toggle input[type="checkbox"]:focus-visible + .toggle-track {
|
|
2104
|
+
outline: 2px solid var(--tg-accent);
|
|
2105
|
+
outline-offset: 2px;
|
|
2106
|
+
}
|
|
2107
|
+
.toggle input[type="checkbox"]:disabled + .toggle-track {
|
|
2108
|
+
opacity: 0.5;
|
|
2109
|
+
}
|
|
2110
|
+
.toggle-label {
|
|
2111
|
+
font-size: 12px;
|
|
2112
|
+
color: var(--tg-text);
|
|
2113
|
+
font-family: var(--tg-mono);
|
|
2114
|
+
}
|
|
2115
|
+
/* Topbar #stat-rag — re-purposed as a live RAG state line */
|
|
2116
|
+
.topbar-stat.rag-on {
|
|
2117
|
+
color: var(--tg-green);
|
|
2118
|
+
font-weight: 500;
|
|
2119
|
+
}
|
|
2120
|
+
.topbar-stat.rag-pending {
|
|
2121
|
+
color: var(--tg-amber);
|
|
2122
|
+
}
|
|
2123
|
+
.topbar-stat.rag-off {
|
|
2124
|
+
color: var(--tg-text-dim);
|
|
2125
|
+
}
|
|
2005
2126
|
.setup-backdrop {
|
|
2006
2127
|
position: absolute;
|
|
2007
2128
|
inset: 0;
|
|
@@ -298,11 +298,107 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
|
|
|
298
298
|
return parsed.projects;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
|
|
302
|
+
// this for the dashboard RAG toggle (PATCH /api/config) but the helper is
|
|
303
|
+
// generic — pass a deep partial of the config tree, every leaf in `patch` that
|
|
304
|
+
// matches the whitelist gets written through. Returns the parsed-from-disk
|
|
305
|
+
// post-write tree (NOT post-substitution; we only persist user-authored values
|
|
306
|
+
// here, never substituted secrets).
|
|
307
|
+
//
|
|
308
|
+
// Whitelist deliberately tight. Only fields a UI can safely flip live belong
|
|
309
|
+
// here. Adding a new field is an explicit one-line edit (vs. a freeform writer
|
|
310
|
+
// that would let a buggy/malicious client change `port`, `shell`, or projects).
|
|
311
|
+
//
|
|
312
|
+
// Comments and formatting in config.yaml are NOT preserved — same trade-off
|
|
313
|
+
// as `addProject`. The yaml package's parseDocument API can preserve comments
|
|
314
|
+
// but we'd need to migrate addProject too for consistency; that's a follow-up.
|
|
315
|
+
const UPDATABLE_PATHS = new Set([
|
|
316
|
+
'rag.enabled'
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
function flattenPatch(obj, prefix = '') {
|
|
320
|
+
const out = [];
|
|
321
|
+
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return out;
|
|
322
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
323
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
324
|
+
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
|
|
325
|
+
out.push(...flattenPatch(v, key));
|
|
326
|
+
} else {
|
|
327
|
+
out.push([key, v]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setPath(obj, segs, value) {
|
|
334
|
+
let cur = obj;
|
|
335
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
336
|
+
const s = segs[i];
|
|
337
|
+
if (cur[s] == null || typeof cur[s] !== 'object') cur[s] = {};
|
|
338
|
+
cur = cur[s];
|
|
339
|
+
}
|
|
340
|
+
cur[segs[segs.length - 1]] = value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function updateConfig(patch, configPath = CONFIG_PATH) {
|
|
344
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
345
|
+
throw new Error('updateConfig: patch must be a plain object');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const flat = flattenPatch(patch);
|
|
349
|
+
if (flat.length === 0) {
|
|
350
|
+
throw new Error('updateConfig: patch is empty');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [key, val] of flat) {
|
|
354
|
+
if (!UPDATABLE_PATHS.has(key)) {
|
|
355
|
+
throw new Error(`updateConfig: ${key} is not in the updatable whitelist`);
|
|
356
|
+
}
|
|
357
|
+
if (key === 'rag.enabled' && typeof val !== 'boolean') {
|
|
358
|
+
throw new Error('updateConfig: rag.enabled must be a boolean');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const yaml = require('yaml');
|
|
363
|
+
let parsed = {};
|
|
364
|
+
if (fs.existsSync(configPath)) {
|
|
365
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
366
|
+
try {
|
|
367
|
+
parsed = yaml.parse(raw) || {};
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`config.yaml is not valid YAML — refusing to overwrite: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const [key, val] of flat) {
|
|
374
|
+
setPath(parsed, key.split('.'), val);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (fs.existsSync(configPath)) {
|
|
378
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
379
|
+
const bak = `${configPath}.${ts}.bak`;
|
|
380
|
+
try {
|
|
381
|
+
fs.copyFileSync(configPath, bak);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.warn('[config] Could not write backup before updateConfig:', err.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const out = yaml.stringify(parsed);
|
|
388
|
+
fs.writeFileSync(configPath, out, 'utf-8');
|
|
389
|
+
console.log(`[config] updateConfig wrote ${flat.map(([k]) => k).join(', ')}`);
|
|
390
|
+
|
|
391
|
+
return parsed;
|
|
392
|
+
}
|
|
393
|
+
|
|
301
394
|
module.exports = {
|
|
302
395
|
loadConfig,
|
|
303
396
|
addProject,
|
|
397
|
+
updateConfig,
|
|
304
398
|
// exported for tests / introspection
|
|
305
399
|
_parseDotenv: parseDotenv,
|
|
306
400
|
_substituteEnv: substituteEnv,
|
|
401
|
+
_flattenPatch: flattenPatch,
|
|
402
|
+
_UPDATABLE_PATHS: UPDATABLE_PATHS,
|
|
307
403
|
_paths: { CONFIG_DIR, CONFIG_PATH, SECRETS_PATH }
|
|
308
404
|
};
|
|
@@ -61,7 +61,7 @@ const { TranscriptWriter } = require('./transcripts');
|
|
|
61
61
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
62
62
|
const { getFullHealth } = require('./health');
|
|
63
63
|
const { themes, statusColors } = require('./themes');
|
|
64
|
-
const { loadConfig, addProject } = require('./config');
|
|
64
|
+
const { loadConfig, addProject, updateConfig } = require('./config');
|
|
65
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
66
66
|
|
|
67
67
|
function createServer(config) {
|
|
@@ -1069,16 +1069,64 @@ function createServer(config) {
|
|
|
1069
1069
|
res.json(t);
|
|
1070
1070
|
});
|
|
1071
1071
|
|
|
1072
|
-
// GET
|
|
1073
|
-
|
|
1074
|
-
|
|
1072
|
+
// Public-shape helper so GET and PATCH return the same envelope.
|
|
1073
|
+
function publicConfigPayload() {
|
|
1074
|
+
return {
|
|
1075
1075
|
projects: config.projects || {},
|
|
1076
1076
|
defaultTheme: config.defaultTheme,
|
|
1077
|
+
// ragEnabled is the EFFECTIVE state (after credential eligibility).
|
|
1078
|
+
// ragConfigEnabled is the user's intent from config.yaml. The dashboard
|
|
1079
|
+
// toggle reads ragConfigEnabled (intent) but renders a warning when it
|
|
1080
|
+
// diverges from ragEnabled (e.g. enabled in config but Supabase creds
|
|
1081
|
+
// missing → effective state stays off).
|
|
1077
1082
|
ragEnabled: rag.enabled,
|
|
1083
|
+
ragConfigEnabled: !!(config.rag && config.rag.enabled),
|
|
1084
|
+
ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
|
|
1078
1085
|
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
1079
1086
|
statusColors,
|
|
1080
1087
|
firstRun
|
|
1081
|
-
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// GET /api/config - current config (sanitized)
|
|
1092
|
+
app.get('/api/config', (req, res) => {
|
|
1093
|
+
res.json(publicConfigPayload());
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// PATCH /api/config - update writable config fields. Sprint 36 T3 Deliverable A.
|
|
1097
|
+
// Body: { rag: { enabled: boolean } } — the only currently writable path.
|
|
1098
|
+
// Persists to ~/.termdeck/config.yaml, live-updates the in-memory integration,
|
|
1099
|
+
// and broadcasts a `config_changed` WS event so all open dashboards re-render
|
|
1100
|
+
// their RAG indicator without a refresh.
|
|
1101
|
+
app.patch('/api/config', (req, res) => {
|
|
1102
|
+
const body = req.body;
|
|
1103
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
1104
|
+
return res.status(400).json({ error: 'body must be a JSON object' });
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
updateConfig(body);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return res.status(400).json({ error: err.message });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (body.rag && typeof body.rag.enabled === 'boolean') {
|
|
1113
|
+
rag.setEnabled(body.rag.enabled);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const payload = publicConfigPayload();
|
|
1117
|
+
|
|
1118
|
+
try {
|
|
1119
|
+
const wsPayload = JSON.stringify({ type: 'config_changed', config: payload });
|
|
1120
|
+
wss.clients.forEach((client) => {
|
|
1121
|
+
if (client.readyState === 1) {
|
|
1122
|
+
try { client.send(wsPayload); } catch (err) { console.error('[ws] config_changed send failed:', err); }
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
console.error('[ws] config_changed broadcast failed:', err);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
res.json(payload);
|
|
1082
1130
|
});
|
|
1083
1131
|
|
|
1084
1132
|
// POST /api/projects - add a new project on the fly, persist to config.yaml
|
|
@@ -1466,6 +1514,14 @@ function createServer(config) {
|
|
|
1466
1514
|
|
|
1467
1515
|
ws.on('close', () => {
|
|
1468
1516
|
console.log(`[ws] Client disconnected from session ${sessionId}`);
|
|
1517
|
+
// Intentional: PTYs survive WS close. The session stays in the manager,
|
|
1518
|
+
// the PTY keeps running, and reconnecting (?session=<id>) re-binds.
|
|
1519
|
+
// PTY teardown happens only via DELETE /api/sessions/:id (user-initiated)
|
|
1520
|
+
// or the PTY's own exit event. Hard-refresh is therefore non-destructive.
|
|
1521
|
+
// Sprint 36 T3 Deliverable C audit (2026-04-27): the briefing predicted
|
|
1522
|
+
// this handler would call pty.kill() — it does not. Joshua's original
|
|
1523
|
+
// hard-refresh-loses-PTYs symptom was the reclaimStalePort SIGKILL chain
|
|
1524
|
+
// (orchestrator hotfix #2, 15:25 ET), not a WS-close cascade.
|
|
1469
1525
|
if (session.ws === ws) {
|
|
1470
1526
|
session.ws = null;
|
|
1471
1527
|
}
|
|
@@ -66,6 +66,14 @@ class RAGIntegration {
|
|
|
66
66
|
this._circuitBreaker = new Map(); // table -> { count, open, openedAt, halfOpen }
|
|
67
67
|
this._halfOpenDelayMs = 5 * 60 * 1000;
|
|
68
68
|
|
|
69
|
+
// Status-change debounce: rapid `active ↔ thinking` cycling from busy
|
|
70
|
+
// Claude Code workers (4+1 sprint lanes) produces dozens of status_changed
|
|
71
|
+
// events per second. Untreated, this floods stdout and the outbox; on
|
|
72
|
+
// 2026-04-27 it contributed to two server-kill incidents during Sprint 36.
|
|
73
|
+
// Drop intra-second status edges; let error transitions always pass.
|
|
74
|
+
this._statusWriteAt = new Map(); // sessionId -> last write timestamp (ms)
|
|
75
|
+
this._statusDebounceMs = 1000;
|
|
76
|
+
|
|
69
77
|
if (this.enabled) {
|
|
70
78
|
this._startSync();
|
|
71
79
|
}
|
|
@@ -148,6 +156,16 @@ class RAGIntegration {
|
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
onStatusChanged(session, oldStatus, newStatus) {
|
|
159
|
+
// Always pass through error transitions — those carry signal worth ingesting
|
|
160
|
+
// every time. Debounce only the active ↔ thinking churn that floods the log
|
|
161
|
+
// when a worker cycles tool calls rapidly.
|
|
162
|
+
const isError = newStatus === 'errored' || oldStatus === 'errored';
|
|
163
|
+
if (!isError) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const last = this._statusWriteAt.get(session.id) || 0;
|
|
166
|
+
if (now - last < this._statusDebounceMs) return;
|
|
167
|
+
this._statusWriteAt.set(session.id, now);
|
|
168
|
+
}
|
|
151
169
|
this._recordForSession(session, 'status_changed', {
|
|
152
170
|
from: oldStatus,
|
|
153
171
|
to: newStatus,
|
|
@@ -351,7 +369,32 @@ class RAGIntegration {
|
|
|
351
369
|
stop() {
|
|
352
370
|
if (this._syncTimer) {
|
|
353
371
|
clearInterval(this._syncTimer);
|
|
372
|
+
this._syncTimer = null;
|
|
373
|
+
}
|
|
374
|
+
this._statusWriteAt.clear();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
|
|
378
|
+
// Re-evaluates eligibility — flipping `enabled: true` without configured
|
|
379
|
+
// Supabase creds is a no-op so the live integration never claims to be on
|
|
380
|
+
// when it can't actually push. Returns the resolved effective flag.
|
|
381
|
+
setEnabled(value) {
|
|
382
|
+
const desired = !!value;
|
|
383
|
+
if (this.config && this.config.rag) {
|
|
384
|
+
this.config.rag.enabled = desired;
|
|
385
|
+
}
|
|
386
|
+
const effective = !!(desired && this.supabaseUrl && this.supabaseKey);
|
|
387
|
+
if (effective === this.enabled) return effective;
|
|
388
|
+
this.enabled = effective;
|
|
389
|
+
if (effective) {
|
|
390
|
+
if (!this._syncTimer) this._startSync();
|
|
391
|
+
} else {
|
|
392
|
+
if (this._syncTimer) {
|
|
393
|
+
clearInterval(this._syncTimer);
|
|
394
|
+
this._syncTimer = null;
|
|
395
|
+
}
|
|
354
396
|
}
|
|
397
|
+
return effective;
|
|
355
398
|
}
|
|
356
399
|
}
|
|
357
400
|
|