@jhizzard/termdeck 0.6.9 → 0.7.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/README.md CHANGED
@@ -171,7 +171,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
171
171
  - **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
172
172
  - **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
173
173
  - **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
174
- - **Not proven at scale.** v0.6.4, validated against 4,669 memories in one developer's production store. The Rumen 2026-04-19 re-kickstart processed 166 sessions into 166 insights in ~5.5 minutes. No multi-user data yet. Bug reports and issues welcome.
174
+ - **Not proven at scale.** v0.7.0, validated against 4,669 memories in one developer's production store. The Rumen 2026-04-19 re-kickstart processed 166 sessions into 166 insights in ~5.5 minutes. No multi-user data yet. Bug reports and issues welcome.
175
175
 
176
176
  ---
177
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.6.9",
3
+ "version": "0.7.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"
@@ -159,6 +159,7 @@
159
159
  `<option value="${tid}" ${tid === meta.theme ? 'selected' : ''}>${t.label}</option>`
160
160
  ).join('')}
161
161
  </select>
162
+ <a class="theme-reset" id="theme-reset-${id}" href="javascript:void(0)" onclick="resetTheme('${id}')" title="Revert to project / global default from config.yaml" style="font-size:11px;color:#7aa2f7;text-decoration:none;margin-left:4px;opacity:0.7;cursor:pointer">↺ default</a>
162
163
  <button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
163
164
  <button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
164
165
  <button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
@@ -1309,10 +1310,25 @@
1309
1310
  const themeObj = getThemeObject(themeId);
1310
1311
  entry.terminal.options.theme = themeObj;
1311
1312
 
1312
- // Persist to server
1313
+ // Persist to server (writes to sessions.theme_override server-side)
1313
1314
  api('PATCH', `/api/sessions/${id}`, { theme: themeId });
1314
1315
  }
1315
1316
 
1317
+ // v0.7.0: clear sessions.theme_override server-side and snap the panel back
1318
+ // to whatever the resolver currently picks (project default → global default
1319
+ // → tokyo-night). Server returns the resolved value in the PATCH response so
1320
+ // the dropdown + xterm theme update without waiting for the 2s broadcast.
1321
+ async function resetTheme(id) {
1322
+ const entry = state.sessions.get(id);
1323
+ if (!entry) return;
1324
+ const updated = await api('PATCH', '/api/sessions/' + id, { theme: null });
1325
+ const resolved = updated && updated.meta && updated.meta.theme;
1326
+ if (!resolved) return;
1327
+ entry.terminal.options.theme = getThemeObject(resolved);
1328
+ const sel = document.getElementById('theme-' + id);
1329
+ if (sel && sel.value !== resolved) sel.value = resolved;
1330
+ }
1331
+
1316
1332
  async function askAI(id, question) {
1317
1333
  if (!question.trim()) return;
1318
1334
  const entry = state.sessions.get(id);
@@ -1,4 +1,4 @@
1
- // Optional token authentication for TermDeck (Sprint 9 T3).
1
+ // Optional token authentication for TermDeck (Sprint 9 T3, extended Sprint 32).
2
2
  //
3
3
  // When no token is configured, auth is a no-op — `createAuthMiddleware()` returns
4
4
  // null and callers skip wiring. When a token is configured (config.auth.token
@@ -9,8 +9,19 @@
9
9
  // - termdeck_token=<token> cookie
10
10
  //
11
11
  // Browser requests without a valid token receive a minimal HTML login page that
12
- // stores the token in a cookie client-side and retries. API requests get a
13
- // JSON 401.
12
+ // POSTs to /api/auth/login. The login handler validates the token and issues a
13
+ // Set-Cookie header with HttpOnly + SameSite=Lax + Max-Age=2592000 (30 days),
14
+ // and Secure when the request was over HTTPS (direct or via X-Forwarded-Proto).
15
+ // API requests get a JSON 401.
16
+ //
17
+ // 30-day cookie trade-off (v0.7.0):
18
+ // TermDeck is intended as a local dev tool. Brad's 2026-04-26 feedback ("is
19
+ // there a way not to have to enter the token at each termdeck session?")
20
+ // showed the per-browser-session re-prompt was a real adoption tax. The
21
+ // compromise risk of a longer cookie is bounded by the local-only attack
22
+ // surface (HttpOnly blocks JS exfiltration, SameSite=Lax blocks CSRF on
23
+ // cross-site POSTs, Secure-when-HTTPS prevents plaintext sniffing on the
24
+ // reverse-proxy path documented in docs/DEPLOYMENT.md). UX wins.
14
25
 
15
26
  function getConfiguredToken(config) {
16
27
  const fromConfig = config && config.auth && config.auth.token;
@@ -63,6 +74,51 @@ function extractToken(req) {
63
74
  return null;
64
75
  }
65
76
 
77
+ // 30 days in seconds — see head-of-file trade-off note.
78
+ const COOKIE_MAX_AGE_SECONDS = 2592000;
79
+
80
+ // HTTPS detection for the Secure cookie flag. Express sets req.protocol from
81
+ // the socket; behind a reverse proxy with `app.set('trust proxy', ...)` it
82
+ // reads X-Forwarded-Proto. We also fall back to reading the header directly
83
+ // so the helper is correct even when trust-proxy isn't enabled.
84
+ function isSecureRequest(req) {
85
+ if (!req) return false;
86
+ if (req.protocol === 'https') return true;
87
+ const xfp = req.headers && req.headers['x-forwarded-proto'];
88
+ if (typeof xfp === 'string') {
89
+ const first = xfp.split(',')[0].trim().toLowerCase();
90
+ if (first === 'https') return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function buildAuthCookie(token, secure) {
96
+ const parts = [
97
+ `termdeck_token=${encodeURIComponent(token)}`,
98
+ 'Path=/',
99
+ `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
100
+ 'HttpOnly',
101
+ 'SameSite=Lax',
102
+ ];
103
+ if (secure) parts.push('Secure');
104
+ return parts.join('; ');
105
+ }
106
+
107
+ function writeAuthCookie(req, res, token) {
108
+ res.setHeader('Set-Cookie', buildAuthCookie(token, isSecureRequest(req)));
109
+ }
110
+
111
+ function handleLogin(req, res, configuredToken) {
112
+ const provided =
113
+ (req.body && typeof req.body.token === 'string' && req.body.token.trim()) ||
114
+ extractToken(req);
115
+ if (!provided || provided !== configuredToken) {
116
+ return res.status(401).json({ error: 'unauthorized' });
117
+ }
118
+ writeAuthCookie(req, res, configuredToken);
119
+ return res.status(200).json({ ok: true });
120
+ }
121
+
66
122
  function loginPage() {
67
123
  return `<!doctype html>
68
124
  <html lang="en">
@@ -104,12 +160,14 @@ function submitToken(e) {
104
160
  err.textContent = '';
105
161
  var t = document.getElementById('t').value.trim();
106
162
  if (!t) return false;
107
- document.cookie = 'termdeck_token=' + encodeURIComponent(t) +
108
- '; path=/; SameSite=Strict; Max-Age=2592000';
109
163
  var next = new URLSearchParams(location.search).get('next') || '/';
110
- fetch('/api/config', { credentials: 'same-origin' }).then(function(r) {
164
+ fetch('/api/auth/login', {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ credentials: 'same-origin',
168
+ body: JSON.stringify({ token: t })
169
+ }).then(function(r) {
111
170
  if (r.ok) { location.href = next; return; }
112
- document.cookie = 'termdeck_token=; path=/; Max-Age=0';
113
171
  err.textContent = 'Invalid token.';
114
172
  }).catch(function() {
115
173
  err.textContent = 'Network error.';
@@ -130,6 +188,13 @@ function createAuthMiddleware(config) {
130
188
  // without being handed a secret.
131
189
  if (req.path === '/api/health') return next();
132
190
 
191
+ // The login endpoint validates credentials and issues the cookie itself —
192
+ // it must run before the token-required check below or browsers could
193
+ // never reach it.
194
+ if (req.method === 'POST' && req.path === '/api/auth/login') {
195
+ return handleLogin(req, res, token);
196
+ }
197
+
133
198
  const provided = extractToken(req);
134
199
  if (provided && provided === token) return next();
135
200
 
@@ -168,5 +233,11 @@ module.exports = {
168
233
  verifyWebSocketUpgrade,
169
234
  getConfiguredToken,
170
235
  hasAuth,
171
- loginPage
236
+ loginPage,
237
+ // Exposed for tests + future reuse by other server modules.
238
+ buildAuthCookie,
239
+ isSecureRequest,
240
+ writeAuthCookie,
241
+ handleLogin,
242
+ COOKIE_MAX_AGE_SECONDS,
172
243
  };
@@ -27,7 +27,8 @@ function initDatabase(Database) {
27
27
  exited_at TEXT,
28
28
  exit_code INTEGER,
29
29
  reason TEXT,
30
- theme TEXT DEFAULT 'tokyo-night'
30
+ theme TEXT DEFAULT 'tokyo-night',
31
+ theme_override TEXT
31
32
  );
32
33
 
33
34
  CREATE TABLE IF NOT EXISTS command_history (
@@ -54,7 +55,6 @@ function initDatabase(Database) {
54
55
  CREATE TABLE IF NOT EXISTS projects (
55
56
  name TEXT PRIMARY KEY,
56
57
  path TEXT NOT NULL,
57
- default_theme TEXT DEFAULT 'tokyo-night',
58
58
  default_command TEXT DEFAULT 'bash',
59
59
  rag_namespace TEXT,
60
60
  created_at TEXT NOT NULL
@@ -79,6 +79,42 @@ function initDatabase(Database) {
79
79
  console.warn('[db] command_history.source migration failed:', err.message);
80
80
  }
81
81
 
82
+ // Migration (v0.7.0): add sessions.theme_override and backfill from theme.
83
+ // The backfill runs exactly once — only when the column is being added — so
84
+ // post-migration sessions created with theme_override=NULL stay un-overridden
85
+ // and pick up config.yaml changes via the theme-resolver. Pre-v0.7.0 rows
86
+ // had `theme` written by the dropdown PATCH, so treating them as user-set is
87
+ // the correct preservation of customizations.
88
+ // (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we PRAGMA-check first — same
89
+ // pattern as the command_history.source migration above.)
90
+ try {
91
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
92
+ const hasOverride = cols.some((c) => c.name === 'theme_override');
93
+ if (!hasOverride) {
94
+ db.exec(`ALTER TABLE sessions ADD COLUMN theme_override TEXT`);
95
+ const updated = db.prepare(`UPDATE sessions SET theme_override = theme WHERE theme IS NOT NULL`).run();
96
+ console.log(`[db] Migrated sessions: added 'theme_override' column, backfilled ${updated.changes} row(s) from theme`);
97
+ }
98
+ } catch (err) {
99
+ console.warn('[db] sessions.theme_override migration failed:', err.message);
100
+ }
101
+
102
+ // Migration (v0.7.0): drop the dead projects.default_theme column. It was
103
+ // CREATE'd in early v0.1 but was never read or written by any code path
104
+ // (see Sprint 32 T1 grep). Removing it eliminates a latent contract-drift
105
+ // trap. SQLite supports DROP COLUMN since 3.35 (well below better-sqlite3's
106
+ // bundled version). No `IF EXISTS` in SQLite, so PRAGMA-check first.
107
+ try {
108
+ const cols = db.prepare(`PRAGMA table_info(projects)`).all();
109
+ const hasDefaultTheme = cols.some((c) => c.name === 'default_theme');
110
+ if (hasDefaultTheme) {
111
+ db.exec(`ALTER TABLE projects DROP COLUMN default_theme`);
112
+ console.log("[db] Migrated projects: dropped dead 'default_theme' column");
113
+ }
114
+ } catch (err) {
115
+ console.warn('[db] projects.default_theme drop migration failed:', err.message);
116
+ }
117
+
82
118
  return db;
83
119
  }
84
120
 
@@ -0,0 +1,377 @@
1
+ // Runtime health snapshot — the v0.7.0 sibling of v0.6.9's
2
+ // auditPreconditions/verifyOutcomes (packages/server/src/setup/preconditions.js).
3
+ //
4
+ // Why this module exists
5
+ // ──────────────────────
6
+ // v0.6.9 closed the install-time precondition-drift class with a front-loaded
7
+ // audit and a post-write verify inside the wizards. That defends the moment
8
+ // the user runs `termdeck init --mnestra` / `--rumen`. It does not defend
9
+ // later — when an extension gets disabled in the Supabase dashboard, when a
10
+ // migration loader picks up a stale set on a subsequent reinstall, or when
11
+ // the cron job is paused without anyone noticing. `/api/health/full` answers
12
+ // "is this install actually healthy *right now*?" by re-running the same
13
+ // SELECTs at runtime instead of install-time.
14
+ //
15
+ // Required vs warn checks
16
+ // ───────────────────────
17
+ // Required checks (sqlite, mnestra-pg, memory-items-col, pg-cron-ext,
18
+ // pg-net-ext, vault-secret, cron-job-active) drive the overall `ok` flag —
19
+ // any non-pass marks the report unhealthy and the route returns 503. Warn
20
+ // checks (mnestra-webhook, rumen-pool) are best-effort: a failure surfaces
21
+ // as `warn` with detail, but does not flip `ok`.
22
+ //
23
+ // Caching
24
+ // ───────
25
+ // Reports cached in module scope for 30s. `getFullHealth(config, { refresh: true })`
26
+ // or the `?refresh=1` query param bypasses the cache. The TTL is reflected
27
+ // in the response's `ttlSeconds` field so polling clients can self-pace.
28
+ //
29
+ // Error handling
30
+ // ──────────────
31
+ // Every check is wrapped: any unexpected error downgrades that single check
32
+ // to `fail` (or `warn` for warn-checks) with the error message in `detail`.
33
+ // `getFullHealth()` always resolves with a structured report — never throws.
34
+
35
+ 'use strict';
36
+
37
+ const http = require('http');
38
+ const https = require('https');
39
+
40
+ const TTL_SECONDS = 30;
41
+ const TTL_MS = TTL_SECONDS * 1000;
42
+
43
+ const REQUIRED_CHECKS = new Set([
44
+ 'sqlite',
45
+ 'mnestra-pg',
46
+ 'memory-items-col',
47
+ 'pg-cron-ext',
48
+ 'pg-net-ext',
49
+ 'vault-secret',
50
+ 'cron-job-active'
51
+ ]);
52
+
53
+ let _cache = null;
54
+ let _cachedAt = 0;
55
+
56
+ // ── SQLite check ────────────────────────────────────────────────────────────
57
+
58
+ function checkSqlite(db) {
59
+ if (!db) {
60
+ return { name: 'sqlite', status: 'fail', detail: 'better-sqlite3 not initialized' };
61
+ }
62
+ try {
63
+ const row = db.prepare('SELECT 1 AS ok').get();
64
+ if (row && row.ok === 1) return { name: 'sqlite', status: 'pass' };
65
+ return { name: 'sqlite', status: 'fail', detail: 'SELECT 1 returned unexpected result' };
66
+ } catch (err) {
67
+ return { name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) };
68
+ }
69
+ }
70
+
71
+ // ── Postgres-side checks ────────────────────────────────────────────────────
72
+ //
73
+ // These mirror auditRumenPreconditions + verifyMnestraOutcomes + verifyRumenOutcomes
74
+ // from setup/preconditions.js. We deliberately copy the SQL rather than
75
+ // import the helper — preconditions.js is owned by other lanes and concurrent
76
+ // edits to share code would step on T1/T4. The queries are small and stable.
77
+
78
+ async function safeQueryRow(client, sql) {
79
+ try {
80
+ const r = await client.query(sql);
81
+ if (r.rows && r.rows.length > 0 && r.rows[0].ok) return { ok: true };
82
+ return { ok: false };
83
+ } catch (err) {
84
+ return { error: err && err.message ? err.message : String(err) };
85
+ }
86
+ }
87
+
88
+ async function safeQueryRows(client, sql) {
89
+ try {
90
+ const r = await client.query(sql);
91
+ return { rows: r.rows || [] };
92
+ } catch (err) {
93
+ return { error: err && err.message ? err.message : String(err) };
94
+ }
95
+ }
96
+
97
+ async function openPgClient(databaseUrl) {
98
+ if (!databaseUrl) return null;
99
+ let pgRunner;
100
+ try { pgRunner = require('./setup/pg-runner'); } catch (_e) { return null; }
101
+ try { return await pgRunner.connect(databaseUrl); } catch (_e) { return null; }
102
+ }
103
+
104
+ async function runPgChecks({ databaseUrl, _pgClient }) {
105
+ const checks = [];
106
+ const client = _pgClient || (await openPgClient(databaseUrl));
107
+ const owned = !_pgClient;
108
+
109
+ if (!client) {
110
+ checks.push({
111
+ name: 'mnestra-pg',
112
+ status: 'fail',
113
+ detail: databaseUrl
114
+ ? 'could not connect to Postgres using DATABASE_URL'
115
+ : 'DATABASE_URL not configured (set in ~/.termdeck/secrets.env)'
116
+ });
117
+ // Dependent checks can't run without a connection — surface them as
118
+ // fail rather than silently skipping so the report is complete.
119
+ for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
120
+ checks.push({ name, status: 'fail', detail: 'pg unavailable' });
121
+ }
122
+ return checks;
123
+ }
124
+
125
+ try {
126
+ const ping = await safeQueryRow(client, 'SELECT 1 AS ok');
127
+ if (ping.error) {
128
+ checks.push({ name: 'mnestra-pg', status: 'fail', detail: ping.error });
129
+ } else if (!ping.ok) {
130
+ checks.push({ name: 'mnestra-pg', status: 'fail', detail: 'SELECT 1 returned no row' });
131
+ } else {
132
+ checks.push({ name: 'mnestra-pg', status: 'pass' });
133
+ }
134
+
135
+ // memory_items.source_session_id — the v0.6.5 column from Brad's saga.
136
+ const col = await safeQueryRow(client,
137
+ "SELECT 1 AS ok FROM information_schema.columns " +
138
+ "WHERE table_schema = 'public' AND table_name = 'memory_items' AND column_name = 'source_session_id'");
139
+ if (col.error) {
140
+ checks.push({ name: 'memory-items-col', status: 'fail', detail: col.error });
141
+ } else if (!col.ok) {
142
+ checks.push({
143
+ name: 'memory-items-col',
144
+ status: 'fail',
145
+ detail:
146
+ 'memory_items.source_session_id missing — re-run termdeck init --mnestra --yes ' +
147
+ '(if loader picked up a stale set, first: npm cache clean --force && npm i -g @jhizzard/termdeck@latest)'
148
+ });
149
+ } else {
150
+ checks.push({ name: 'memory-items-col', status: 'pass' });
151
+ }
152
+
153
+ const cron = await safeQueryRow(client,
154
+ "SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_cron'");
155
+ if (cron.error) {
156
+ checks.push({ name: 'pg-cron-ext', status: 'fail', detail: cron.error });
157
+ } else if (!cron.ok) {
158
+ checks.push({
159
+ name: 'pg-cron-ext',
160
+ status: 'fail',
161
+ detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_cron'
162
+ });
163
+ } else {
164
+ checks.push({ name: 'pg-cron-ext', status: 'pass' });
165
+ }
166
+
167
+ const net = await safeQueryRow(client,
168
+ "SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_net'");
169
+ if (net.error) {
170
+ checks.push({ name: 'pg-net-ext', status: 'fail', detail: net.error });
171
+ } else if (!net.ok) {
172
+ checks.push({
173
+ name: 'pg-net-ext',
174
+ status: 'fail',
175
+ detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_net'
176
+ });
177
+ } else {
178
+ checks.push({ name: 'pg-net-ext', status: 'pass' });
179
+ }
180
+
181
+ const vault = await safeQueryRow(client,
182
+ "SELECT 1 AS ok FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key'");
183
+ if (vault.error) {
184
+ checks.push({
185
+ name: 'vault-secret',
186
+ status: 'fail',
187
+ detail: `vault.decrypted_secrets unreadable — ${vault.error}`
188
+ });
189
+ } else if (!vault.ok) {
190
+ checks.push({
191
+ name: 'vault-secret',
192
+ status: 'fail',
193
+ detail: 'rumen_service_role_key missing — Supabase dashboard → Project Settings → Vault → New secret'
194
+ });
195
+ } else {
196
+ checks.push({ name: 'vault-secret', status: 'pass' });
197
+ }
198
+
199
+ const job = await safeQueryRows(client,
200
+ "SELECT active FROM cron.job WHERE jobname = 'rumen-tick'");
201
+ if (job.error) {
202
+ checks.push({ name: 'cron-job-active', status: 'fail', detail: `cron.job unreadable — ${job.error}` });
203
+ } else if (!job.rows || job.rows.length === 0) {
204
+ checks.push({
205
+ name: 'cron-job-active',
206
+ status: 'fail',
207
+ detail: 'rumen-tick row not found — re-run `termdeck init --rumen`'
208
+ });
209
+ } else if (!job.rows[0].active) {
210
+ checks.push({
211
+ name: 'cron-job-active',
212
+ status: 'fail',
213
+ detail:
214
+ "rumen-tick paused — SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'rumen-tick'), active := true);"
215
+ });
216
+ } else {
217
+ checks.push({ name: 'cron-job-active', status: 'pass' });
218
+ }
219
+ } finally {
220
+ if (owned) {
221
+ try { await client.end(); } catch (_e) { /* ignore */ }
222
+ }
223
+ }
224
+
225
+ return checks;
226
+ }
227
+
228
+ // ── Warn checks ─────────────────────────────────────────────────────────────
229
+
230
+ function httpReachable(url, timeoutMs = 2000) {
231
+ return new Promise((resolve) => {
232
+ const mod = url.startsWith('https:') ? https : http;
233
+ let req;
234
+ try {
235
+ req = mod.get(url, { timeout: timeoutMs }, (res) => {
236
+ const ok = res.statusCode != null && res.statusCode < 500;
237
+ res.resume();
238
+ resolve({ ok, status: res.statusCode });
239
+ });
240
+ } catch (err) {
241
+ resolve({ ok: false, error: err && err.message ? err.message : String(err) });
242
+ return;
243
+ }
244
+ req.on('error', (err) => resolve({ ok: false, error: err && err.message ? err.message : String(err) }));
245
+ req.on('timeout', () => { try { req.destroy(); } catch (_e) { /* gone */ } resolve({ ok: false, error: 'timeout' }); });
246
+ });
247
+ }
248
+
249
+ async function checkMnestraWebhook(config, options) {
250
+ if (options && typeof options._mnestraWebhookProbe === 'function') {
251
+ try {
252
+ const r = await options._mnestraWebhookProbe();
253
+ if (r && r.ok) return { name: 'mnestra-webhook', status: 'pass' };
254
+ return { name: 'mnestra-webhook', status: 'warn', detail: (r && r.detail) || 'unreachable' };
255
+ } catch (err) {
256
+ return { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) };
257
+ }
258
+ }
259
+ const rag = (config && config.rag) || {};
260
+ if (!rag.mnestraWebhookUrl) {
261
+ return { name: 'mnestra-webhook', status: 'warn', detail: 'webhook URL not configured' };
262
+ }
263
+ const healthUrl = String(rag.mnestraWebhookUrl).replace(/\/mnestra\/?$/, '/healthz');
264
+ const r = await httpReachable(healthUrl, 2000);
265
+ if (r.ok) return { name: 'mnestra-webhook', status: 'pass' };
266
+ return {
267
+ name: 'mnestra-webhook',
268
+ status: 'warn',
269
+ detail: r.error ? `unreachable — ${r.error}` : `HTTP ${r.status || '???'}`
270
+ };
271
+ }
272
+
273
+ async function checkRumenPool(config, options) {
274
+ if (options && typeof options._rumenPoolProbe === 'function') {
275
+ try {
276
+ const r = await options._rumenPoolProbe();
277
+ if (r && r.ok) return { name: 'rumen-pool', status: 'pass' };
278
+ return { name: 'rumen-pool', status: 'warn', detail: (r && r.detail) || 'unreachable (best-effort)' };
279
+ } catch (err) {
280
+ return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
281
+ }
282
+ }
283
+ let pg;
284
+ try { pg = require('pg'); } catch (_e) { pg = null; }
285
+ if (!pg) return { name: 'rumen-pool', status: 'warn', detail: 'pg module not installed' };
286
+
287
+ const dbUrl = (config && config.rag && config.rag.databaseUrl) || process.env.DATABASE_URL;
288
+ if (!dbUrl) return { name: 'rumen-pool', status: 'warn', detail: 'DATABASE_URL not set' };
289
+
290
+ const pool = new pg.Pool({ connectionString: dbUrl, max: 1, connectionTimeoutMillis: 3000 });
291
+ try {
292
+ const res = await pool.query('SELECT 1 AS ok');
293
+ if (res.rows[0] && res.rows[0].ok === 1) return { name: 'rumen-pool', status: 'pass' };
294
+ return { name: 'rumen-pool', status: 'warn', detail: 'SELECT 1 returned unexpected result' };
295
+ } catch (err) {
296
+ return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
297
+ } finally {
298
+ try { await pool.end(); } catch (_e) { /* ignore */ }
299
+ }
300
+ }
301
+
302
+ // ── Aggregator ──────────────────────────────────────────────────────────────
303
+
304
+ async function getFullHealth(config = {}, options = {}) {
305
+ const refresh = !!options.refresh;
306
+ if (!refresh && _cache && (Date.now() - _cachedAt) < TTL_MS) {
307
+ return _cache;
308
+ }
309
+
310
+ const db = options.db || (config && config._db) || null;
311
+ const databaseUrl =
312
+ options.databaseUrl ||
313
+ (config && config.rag && config.rag.databaseUrl) ||
314
+ process.env.DATABASE_URL ||
315
+ null;
316
+
317
+ const checks = [];
318
+
319
+ // 1. SQLite (sync — small DB, no risk of blocking)
320
+ try { checks.push(checkSqlite(db)); }
321
+ catch (err) { checks.push({ name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) }); }
322
+
323
+ // 2-7. Postgres-side suite
324
+ let pgChecks;
325
+ try { pgChecks = await runPgChecks({ databaseUrl, _pgClient: options._pgClient }); }
326
+ catch (err) {
327
+ pgChecks = [{
328
+ name: 'mnestra-pg',
329
+ status: 'fail',
330
+ detail: err && err.message ? err.message : String(err)
331
+ }];
332
+ for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
333
+ pgChecks.push({ name, status: 'fail', detail: 'pg suite aborted' });
334
+ }
335
+ }
336
+ for (const c of pgChecks) checks.push(c);
337
+
338
+ // 8. Mnestra webhook (warn)
339
+ let webhook;
340
+ try { webhook = await checkMnestraWebhook(config, options); }
341
+ catch (err) { webhook = { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
342
+ checks.push(webhook);
343
+
344
+ // 9. Rumen pool (warn)
345
+ let pool;
346
+ try { pool = await checkRumenPool(config, options); }
347
+ catch (err) { pool = { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
348
+ checks.push(pool);
349
+
350
+ const ok = checks
351
+ .filter((c) => REQUIRED_CHECKS.has(c.name))
352
+ .every((c) => c.status === 'pass');
353
+
354
+ const report = {
355
+ ok,
356
+ timestamp: new Date().toISOString(),
357
+ ttlSeconds: TTL_SECONDS,
358
+ checks
359
+ };
360
+
361
+ _cache = report;
362
+ _cachedAt = Date.now();
363
+
364
+ return report;
365
+ }
366
+
367
+ // Test seam — drop the cache between cases so call-count assertions hold.
368
+ function _resetCache() {
369
+ _cache = null;
370
+ _cachedAt = 0;
371
+ }
372
+
373
+ module.exports = {
374
+ getFullHealth,
375
+ REQUIRED_CHECKS,
376
+ _resetCache
377
+ };
@@ -59,6 +59,7 @@ const { createBridge } = require('./mnestra-bridge');
59
59
  const { writeSessionLog } = require('./session-logger');
60
60
  const { TranscriptWriter } = require('./transcripts');
61
61
  const { createHealthHandler, runPreflight } = require('./preflight');
62
+ const { getFullHealth } = require('./health');
62
63
  const { themes, statusColors } = require('./themes');
63
64
  const { loadConfig, addProject } = require('./config');
64
65
  const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
@@ -146,6 +147,24 @@ function createServer(config) {
146
147
  // or scope the response to a minimal {status, version} payload.
147
148
  app.get('/api/health', createHealthHandler(config));
148
149
 
150
+ // GET /api/health/full - v0.7.0 runtime health snapshot (Sprint 32 T3)
151
+ // Mirrors the install-time auditPreconditions/verifyOutcomes pattern from
152
+ // v0.6.9 at runtime: re-runs the same SELECTs against pg_extension,
153
+ // vault.decrypted_secrets, cron.job, and information_schema.columns so a
154
+ // post-install drift (extension toggled off, schedule paused, stale loader
155
+ // shadow) is observable without a re-install. Cached 30s; pass ?refresh=1
156
+ // to bypass. Required checks drive the response status (200 ok / 503 fail);
157
+ // warn checks (mnestra-webhook, rumen-pool) never flip ok.
158
+ app.get('/api/health/full', async (req, res) => {
159
+ const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
160
+ try {
161
+ const report = await getFullHealth(config, { refresh, db });
162
+ res.status(report.ok ? 200 : 503).json(report);
163
+ } catch (err) {
164
+ res.status(500).json({ ok: false, error: err && err.message ? err.message : String(err) });
165
+ }
166
+ });
167
+
149
168
  // GET /api/setup - setup wizard tier status (Sprint 19 T1)
150
169
  // Reuses preflight checks (mnestra_reachable, rumen_recent) and pairs them
151
170
  // with filesystem + config signals to classify which of the 4 TermDeck tiers
@@ -1,9 +1,19 @@
1
1
  // Session manager - PTY lifecycle, metadata tracking, output analysis
2
- // Each session wraps a node-pty instance with rich metadata
2
+ // Each session wraps a node-pty instance with rich metadata.
3
+ //
4
+ // v0.7.0 theme model (see theme-resolver.js): meta.theme is a *getter* that
5
+ // resolves at read time from { session.theme_override → project default →
6
+ // global default → 'tokyo-night' }. The session no longer snapshots a theme
7
+ // string into meta at construction. This is the getter form (vs. the
8
+ // alternative of recomputing-and-writing on every metadata broadcast) because
9
+ // it makes the resolution path explicit at every read site and keeps the
10
+ // metadata broadcast in index.js untouched — `s.meta.theme` already returns
11
+ // the right thing whenever index.js dereferences it.
3
12
 
4
13
  const { v4: uuidv4 } = require('uuid');
5
14
  const os = require('os');
6
15
  const path = require('path');
16
+ const { resolveTheme } = require('./theme-resolver');
7
17
 
8
18
  // Strip ANSI escape codes for pattern matching
9
19
  function stripAnsi(str) {
@@ -69,6 +79,24 @@ class Session {
69
79
  this.pty = null;
70
80
  this.ws = null;
71
81
 
82
+ // v0.7.0: theme_override is the user's explicit dropdown choice (NULL = no
83
+ // override → resolveTheme falls through to project / global default at
84
+ // read time). The legacy `options.theme` argument is intentionally NOT
85
+ // stored as an override here — it arrives from index.js already
86
+ // pre-defaulted (`theme || project.defaultTheme || config.defaultTheme`),
87
+ // which means we can no longer distinguish a real user choice from the
88
+ // server-filled default at the create-call boundary. Real overrides come
89
+ // through PATCH /api/sessions/:id (see updateMeta below).
90
+ this.theme_override = options.themeOverride != null ? options.themeOverride : null;
91
+
92
+ // Project (mirrored from meta for theme-resolver convenience — resolveTheme
93
+ // reads `session.project`, not `session.meta.project`).
94
+ Object.defineProperty(this, 'project', {
95
+ get: () => this.meta.project,
96
+ enumerable: false,
97
+ configurable: true
98
+ });
99
+
72
100
  // Metadata
73
101
  this.meta = {
74
102
  type: options.type || 'shell', // shell, claude-code, gemini, python-server, one-shot
@@ -89,14 +117,23 @@ class Session {
89
117
  exitCode: null,
90
118
  childProcesses: [],
91
119
 
92
- // Theme
93
- theme: options.theme || 'tokyo-night',
94
-
95
120
  // RAG
96
121
  ragEnabled: options.ragEnabled !== false,
97
122
  ragEvents: [] // buffer before flush
98
123
  };
99
124
 
125
+ // theme is render-time resolved (see header comment + theme-resolver.js).
126
+ // Reads call resolveTheme(this, undefined) which falls back to the cached
127
+ // ~/.termdeck/config.yaml. Writes route to theme_override so PATCH/UPDATE
128
+ // through `session.meta.theme = 'dracula'` persists correctly. Setting
129
+ // null clears the override and reverts to the config-derived default.
130
+ Object.defineProperty(this.meta, 'theme', {
131
+ get: () => resolveTheme(this),
132
+ set: (val) => { this.theme_override = val == null ? null : val; },
133
+ enumerable: true,
134
+ configurable: true
135
+ });
136
+
100
137
  // Transcript chunk counter — monotonic per session for deterministic replay
101
138
  this.transcriptChunkIndex = 0;
102
139
 
@@ -383,11 +420,16 @@ class SessionManager {
383
420
  const session = new Session(options);
384
421
  this.sessions.set(session.id, session);
385
422
 
386
- // Persist to SQLite
423
+ // Persist to SQLite. Both columns get written:
424
+ // theme — legacy; the resolved value at create time, kept for
425
+ // backward-compat with any consumer that still reads it.
426
+ // Not authoritative post-v0.7.0.
427
+ // theme_override — v0.7.0 authoritative column. NULL on create — only
428
+ // a PATCH from the dropdown sets it (see updateMeta).
387
429
  if (this.db) {
388
430
  this.db.prepare(`
389
- INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme)
390
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
431
+ INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override)
432
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
391
433
  `).run(
392
434
  session.id,
393
435
  session.meta.type,
@@ -397,7 +439,8 @@ class SessionManager {
397
439
  session.meta.cwd,
398
440
  session.meta.createdAt,
399
441
  session.meta.reason,
400
- session.meta.theme
442
+ session.meta.theme, // resolved snapshot, legacy column
443
+ session.theme_override // NULL by default
401
444
  );
402
445
  }
403
446
 
@@ -435,14 +478,16 @@ class SessionManager {
435
478
  const applied = {};
436
479
  for (const [key, val] of Object.entries(updates)) {
437
480
  if (!SessionManager.PATCHABLE_META_FIELDS.has(key)) continue;
438
- session.meta[key] = val;
481
+ session.meta[key] = val; // theme assignment routes through the setter → theme_override
439
482
  applied[key] = val;
440
483
  }
441
484
 
442
- // Persist theme changes to SQLite
443
- if (applied.theme && this.db) {
444
- this.db.prepare('UPDATE sessions SET theme = ? WHERE id = ?')
445
- .run(applied.theme, id);
485
+ // Persist theme changes to SQLite. v0.7.0: writes go to theme_override
486
+ // (the authoritative column); a `theme: null` PATCH clears the override
487
+ // and reverts the session to the config-derived default at next read.
488
+ if ('theme' in applied && this.db) {
489
+ this.db.prepare('UPDATE sessions SET theme_override = ? WHERE id = ?')
490
+ .run(applied.theme == null ? null : applied.theme, id);
446
491
  }
447
492
 
448
493
  this._emit('session:updated', session);
@@ -0,0 +1,77 @@
1
+ // Theme resolver — render-time theme resolution (v0.7.0).
2
+ //
3
+ // Pre-v0.7.0, the theme each terminal rendered with was snapshotted at session
4
+ // creation time and written into sessions.theme. Editing ~/.termdeck/config.yaml
5
+ // and restarting the server did not change existing terminals' themes — they
6
+ // kept whatever the wizard had written into SQLite at create time.
7
+ // (Brad, 2026-04-26: "ignores changes to config.yaml and is stuck in tokyo night.")
8
+ //
9
+ // v0.7.0 separates "the user explicitly chose a theme for this terminal" from
10
+ // "fall back to whatever the config currently says is the default":
11
+ //
12
+ // sessions.theme_override → user's explicit choice via the dropdown (NULL = no override)
13
+ // config.projects[p].defaultTheme → per-project default in YAML
14
+ // config.defaultTheme → global default in YAML
15
+ // 'tokyo-night' → hard-coded floor
16
+ //
17
+ // resolveTheme(session, config) walks that ladder. Called at *read time*
18
+ // (every metadata broadcast), so editing config.yaml + restarting the server
19
+ // — or just editing it, since getCurrentConfig() invalidates on file mtime —
20
+ // propagates to all un-overridden sessions immediately.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ let _configCache = { mtimeMs: 0, value: null, frozen: false };
27
+
28
+ function getCurrentConfig() {
29
+ // Used as the second-arg fallback when the caller doesn't pass an explicit
30
+ // config (production path: meta.theme getter inside Session). Keyed off the
31
+ // YAML file's mtime so a Brad-style "edit config.yaml + restart" — and even
32
+ // an edit *without* restart — picks up the new defaults on the next read.
33
+ if (_configCache.frozen) return _configCache.value;
34
+ try {
35
+ const cfgPath = path.join(os.homedir(), '.termdeck', 'config.yaml');
36
+ const stat = fs.statSync(cfgPath);
37
+ if (_configCache.value && stat.mtimeMs === _configCache.mtimeMs) {
38
+ return _configCache.value;
39
+ }
40
+ const { loadConfig } = require('./config');
41
+ _configCache = { mtimeMs: stat.mtimeMs, value: loadConfig(), frozen: false };
42
+ return _configCache.value;
43
+ } catch {
44
+ return _configCache.value || {};
45
+ }
46
+ }
47
+
48
+ function resolveTheme(session, config) {
49
+ const cfg = config || getCurrentConfig();
50
+ const project = session && session.project;
51
+ const projectDefault = cfg && cfg.projects && project && cfg.projects[project] && cfg.projects[project].defaultTheme;
52
+ return (
53
+ (session && session.theme_override) ||
54
+ projectDefault ||
55
+ (cfg && cfg.defaultTheme) ||
56
+ 'tokyo-night'
57
+ );
58
+ }
59
+
60
+ // Test seam: freezes the cache so the disk-mtime check is bypassed. Lets unit
61
+ // tests inject a config without an actual ~/.termdeck/config.yaml on disk (or
62
+ // while the user *does* have one — important: the dev box typically has a real
63
+ // config.yaml that would otherwise leak into tests). Production never calls this.
64
+ function _setCachedConfigForTests(value) {
65
+ _configCache = { mtimeMs: Number.MAX_SAFE_INTEGER, value, frozen: true };
66
+ }
67
+
68
+ function _resetCacheForTests() {
69
+ _configCache = { mtimeMs: 0, value: null, frozen: false };
70
+ }
71
+
72
+ module.exports = {
73
+ resolveTheme,
74
+ getCurrentConfig,
75
+ _setCachedConfigForTests,
76
+ _resetCacheForTests
77
+ };