@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.
@@ -0,0 +1,34 @@
1
+ -- termdeck_transcripts: append-only log of all PTY output
2
+ -- Run with: psql -f config/transcript-migration.sql "$DATABASE_URL"
3
+ -- Idempotent — safe to re-run.
4
+
5
+ CREATE TABLE IF NOT EXISTS termdeck_transcripts (
6
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7
+ session_id TEXT NOT NULL, -- TermDeck session UUID
8
+ chunk_index BIGINT NOT NULL, -- monotonic per session, for ordering
9
+ content TEXT NOT NULL, -- raw PTY output (ANSI stripped)
10
+ raw_bytes BIGINT NOT NULL DEFAULT 0, -- byte count before stripping
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12
+ );
13
+
14
+ -- Index for session replay (ordered chunks)
15
+ CREATE INDEX IF NOT EXISTS idx_transcripts_session_order
16
+ ON termdeck_transcripts (session_id, chunk_index);
17
+
18
+ -- Index for time-range queries (crash recovery: "what happened in the last hour?")
19
+ CREATE INDEX IF NOT EXISTS idx_transcripts_created
20
+ ON termdeck_transcripts (created_at DESC);
21
+
22
+ -- Full-text search for finding specific output across all sessions
23
+ ALTER TABLE termdeck_transcripts
24
+ ADD COLUMN IF NOT EXISTS fts tsvector
25
+ GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
26
+
27
+ CREATE INDEX IF NOT EXISTS idx_transcripts_fts
28
+ ON termdeck_transcripts USING GIN (fts);
29
+
30
+ -- RLS: service-role only (no anon access to raw terminal output)
31
+ ALTER TABLE termdeck_transcripts ENABLE ROW LEVEL SECURITY;
32
+
33
+ -- Cleanup policy: transcripts older than 30 days get purged
34
+ -- (implement as a pg_cron job or scheduled function, not in this migration)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.7.2",
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"
@@ -12,6 +12,7 @@
12
12
  "packages/client/public/**",
13
13
  "config/config.example.yaml",
14
14
  "config/secrets.env.example",
15
+ "config/transcript-migration.sql",
15
16
  "LICENSE",
16
17
  "README.md"
17
18
  ],
@@ -1,26 +1,32 @@
1
- // Detection helper for Sprint 24: should `termdeck` (no subcommand)
2
- // auto-route through stack.js? Pure function, isolated for testability —
3
- // the dispatcher in index.js still owns the actual routing decision.
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
- const fs = require('fs');
6
- const os = require('os');
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 };
@@ -1,22 +1,33 @@
1
- // `termdeck doctor` — Sprint 28 T2.
1
+ // `termdeck doctor` — Sprint 28 T2 + Sprint 35 T3.
2
2
  //
3
- // Compares installed versions of the four TermDeck-stack packages against
4
- // the npm registry's `dist-tags.latest` and prints a status table. Zero new
5
- // deps uses only node:https, node:child_process, and process.stdout.
3
+ // Two-section diagnostic:
4
+ // Section 1 (Sprint 28) npm version-check across the four stack packages,
5
+ // comparing installed (`npm ls -g`) to the registry's `dist-tags.latest`.
6
+ // Section 2 (Sprint 35) — Supabase schema state. Connects via DATABASE_URL
7
+ // from ~/.termdeck/secrets.env and verifies the tables / columns / RPCs /
8
+ // extensions that TermDeck + Mnestra + Rumen depend on.
6
9
  //
7
- // Module contract (per docs/sprint-28-update-signal/STATUS.md):
10
+ // Read-only no auto-fix. Each fail prints a remediation hint.
11
+ //
12
+ // Module contract:
8
13
  // module.exports = function doctor(argv): Promise<exitCode>
9
- // 0 = all current
10
- // 1 = at least one update available
11
- // 2 = network/registry failure or unrecoverable error
14
+ // 0 = all current and schema clean
15
+ // 1 = at least one update available OR at least one schema gap
16
+ // 2 = network/registry failure or DB-unreachable when --schema requested
17
+ //
18
+ // Flags:
19
+ // --json Emit a parseable JSON document (shape extended for Sprint 35:
20
+ // `{ exitCode, rows, schema? }` — `rows` retained for back-compat)
21
+ // --no-color Strip ANSI codes
22
+ // --no-schema Skip the Supabase schema section (used by tests + offline runs)
12
23
  //
13
- // `_detectInstalled` and `_fetchLatest` are exposed as properties on the
14
- // exported function so tests can monkey-patch the network/process surface
15
- // without spinning up a real registry. The doctor body calls them via
16
- // `module.exports.<name>` so monkey-patching takes effect at call time.
24
+ // Test seams (monkey-patchable):
25
+ // _detectInstalled / _fetchLatest npm probes (Sprint 28)
26
+ // _runSchemaCheck Supabase probe (Sprint 35) tests stub to `{ skipped: true }`
17
27
 
18
28
  const https = require('https');
19
29
  const { spawn } = require('child_process');
30
+ const path = require('path');
20
31
 
21
32
  const STACK_PACKAGES = [
22
33
  '@jhizzard/termdeck',
@@ -200,9 +211,255 @@ function parseArgv(argv) {
200
211
  return {
201
212
  json: args.includes('--json'),
202
213
  noColor: args.includes('--no-color'),
214
+ noSchema: args.includes('--no-schema'),
215
+ };
216
+ }
217
+
218
+ // ── Sprint 35 T3: Supabase schema-check ────────────────────────────────────
219
+ //
220
+ // Connects via DATABASE_URL from ~/.termdeck/secrets.env and runs the schema
221
+ // invariants TermDeck + Mnestra + Rumen depend on. Read-only — no DDL.
222
+ //
223
+ // Returns `{ skipped, sections, passed, total, hasGaps, error? }` where
224
+ // `sections` is an ordered list of `{ name, checks: [{ label, status, hint? }] }`.
225
+ // `status` is one of 'pass' | 'fail'. A `skipped: true` result short-circuits
226
+ // rendering with an informational note.
227
+
228
+ const SCHEMA_QUERIES = {
229
+ table: (name) =>
230
+ `SELECT EXISTS(SELECT 1 FROM information_schema.tables ` +
231
+ `WHERE table_schema = 'public' AND table_name = '${name}') AS ok`,
232
+ column: (table, column) =>
233
+ `SELECT EXISTS(SELECT 1 FROM information_schema.columns ` +
234
+ `WHERE table_schema = 'public' AND table_name = '${table}' ` +
235
+ `AND column_name = '${column}') AS ok`,
236
+ rpc: (name) =>
237
+ `SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = '${name}') AS ok`,
238
+ extension: (name) =>
239
+ `SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = '${name}') AS ok`,
240
+ };
241
+
242
+ // pgvector ships under extname 'vector' on Supabase; some older installs
243
+ // or self-hosted boxes use 'pgvector' directly. Accept either.
244
+ async function checkPgVector(client) {
245
+ try {
246
+ const r = await client.query(
247
+ "SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname IN ('vector', 'pgvector')) AS ok"
248
+ );
249
+ return r.rows && r.rows[0] && r.rows[0].ok === true;
250
+ } catch (_e) {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ async function probeSchema(client, sql) {
256
+ try {
257
+ const r = await client.query(sql);
258
+ return r.rows && r.rows[0] && r.rows[0].ok === true;
259
+ } catch (_e) {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ async function _runSchemaCheck(opts = {}) {
265
+ const optsObj = opts || {};
266
+ // Lazy-require so users running version-check-only never load pg / fs.
267
+ const fs = require('fs');
268
+ const os = require('os');
269
+ const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
270
+ let pgRunner;
271
+ let dotenv;
272
+ try {
273
+ pgRunner = require(path.join(SETUP_DIR, 'pg-runner'));
274
+ dotenv = require(path.join(SETUP_DIR, 'dotenv-io'));
275
+ } catch (err) {
276
+ return {
277
+ skipped: true,
278
+ reason: `setup helpers unavailable: ${err.message}`,
279
+ sections: [], passed: 0, total: 0, hasGaps: false,
280
+ };
281
+ }
282
+
283
+ const secretsPath = optsObj.secretsPath ||
284
+ path.join(os.homedir(), '.termdeck', 'secrets.env');
285
+ if (!fs.existsSync(secretsPath)) {
286
+ return {
287
+ skipped: true,
288
+ reason: `~/.termdeck/secrets.env not found — run \`termdeck init --mnestra\` first`,
289
+ sections: [], passed: 0, total: 0, hasGaps: false,
290
+ };
291
+ }
292
+ const secrets = optsObj.secrets || dotenv.readSecrets(secretsPath);
293
+ if (!secrets.DATABASE_URL) {
294
+ return {
295
+ skipped: true,
296
+ reason: `DATABASE_URL not set in ${secretsPath}`,
297
+ sections: [], passed: 0, total: 0, hasGaps: false,
298
+ };
299
+ }
300
+
301
+ let client = optsObj._pgClient || null;
302
+ let ownsClient = false;
303
+ if (!client) {
304
+ try {
305
+ client = await pgRunner.connect(secrets.DATABASE_URL);
306
+ ownsClient = true;
307
+ } catch (err) {
308
+ return {
309
+ skipped: false,
310
+ connectError: err.message,
311
+ sections: [], passed: 0, total: 0, hasGaps: true,
312
+ };
313
+ }
314
+ }
315
+
316
+ const sections = [
317
+ { name: 'Mnestra modern schema', checks: [] },
318
+ { name: 'Mnestra legacy schema', checks: [] },
319
+ { name: 'Transcript backup', checks: [] },
320
+ { name: 'Rumen schema', checks: [] },
321
+ { name: 'Postgres extensions', checks: [] },
322
+ ];
323
+
324
+ try {
325
+ // Mnestra modern
326
+ const modern = sections[0].checks;
327
+ for (const t of ['memory_items', 'memory_sessions', 'memory_relationships']) {
328
+ modern.push({
329
+ label: `${t} table`,
330
+ status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
331
+ hint: `run: termdeck init --mnestra (applies migrations 001–007)`,
332
+ });
333
+ }
334
+ modern.push({
335
+ label: `memory_items.source_session_id column (v0.6.5+)`,
336
+ status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
337
+ hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
338
+ });
339
+ for (const fn of ['match_memories', 'search_memories', 'memory_status_aggregation']) {
340
+ modern.push({
341
+ label: `${fn}() RPC`,
342
+ status: (await probeSchema(client, SCHEMA_QUERIES.rpc(fn))) ? 'pass' : 'fail',
343
+ hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
344
+ });
345
+ }
346
+
347
+ // Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
348
+ const legacy = sections[1].checks;
349
+ for (const t of ['mnestra_session_memory', 'mnestra_project_memory', 'mnestra_developer_memory', 'mnestra_commands']) {
350
+ legacy.push({
351
+ label: `${t} table`,
352
+ status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
353
+ hint: `run: termdeck init --mnestra --yes (applies migration 008 — Sprint 35)`,
354
+ });
355
+ }
356
+
357
+ // Transcript
358
+ sections[2].checks.push({
359
+ label: `termdeck_transcripts table`,
360
+ status: (await probeSchema(client, SCHEMA_QUERIES.table('termdeck_transcripts'))) ? 'pass' : 'fail',
361
+ hint: `run: psql "$DATABASE_URL" -f config/transcript-migration.sql`,
362
+ });
363
+
364
+ // Rumen — table existence and the created_at column drift Brad hit
365
+ const rumen = sections[3].checks;
366
+ for (const t of ['rumen_jobs', 'rumen_insights', 'rumen_questions']) {
367
+ const tableOk = await probeSchema(client, SCHEMA_QUERIES.table(t));
368
+ rumen.push({
369
+ label: `${t} table`,
370
+ status: tableOk ? 'pass' : 'fail',
371
+ hint: `run: termdeck init --rumen (applies rumen migration 001)`,
372
+ });
373
+ // Only check the column when the table exists — otherwise the column
374
+ // line is redundant noise.
375
+ if (tableOk) {
376
+ rumen.push({
377
+ label: `${t}.created_at column`,
378
+ status: (await probeSchema(client, SCHEMA_QUERIES.column(t, 'created_at'))) ? 'pass' : 'fail',
379
+ hint: `column drift detected — re-run: termdeck init --rumen`,
380
+ });
381
+ }
382
+ }
383
+
384
+ // Extensions — pg_cron / pg_net / pgvector / pg_trgm / pgcrypto
385
+ const exts = sections[4].checks;
386
+ const dashboardHint = (() => {
387
+ if (!secrets.SUPABASE_URL) return `enable in dashboard: Database → Extensions`;
388
+ const m = String(secrets.SUPABASE_URL).match(/https:\/\/([a-z0-9-]+)\.supabase\.(co|in)/i);
389
+ if (!m) return `enable in dashboard: Database → Extensions`;
390
+ return `enable: https://supabase.com/dashboard/project/${m[1]}/database/extensions`;
391
+ })();
392
+ for (const ext of ['pg_cron', 'pg_net', 'pg_trgm', 'pgcrypto']) {
393
+ exts.push({
394
+ label: `${ext}`,
395
+ status: (await probeSchema(client, SCHEMA_QUERIES.extension(ext))) ? 'pass' : 'fail',
396
+ hint: dashboardHint,
397
+ });
398
+ }
399
+ exts.push({
400
+ label: `pgvector (extname: vector)`,
401
+ status: (await checkPgVector(client)) ? 'pass' : 'fail',
402
+ hint: dashboardHint,
403
+ });
404
+ } finally {
405
+ if (ownsClient) {
406
+ try { await client.end(); } catch (_e) { /* ignore */ }
407
+ }
408
+ }
409
+
410
+ let passed = 0;
411
+ let total = 0;
412
+ for (const s of sections) {
413
+ for (const c of s.checks) {
414
+ total += 1;
415
+ if (c.status === 'pass') passed += 1;
416
+ }
417
+ }
418
+ return {
419
+ skipped: false,
420
+ sections,
421
+ passed,
422
+ total,
423
+ hasGaps: passed < total,
203
424
  };
204
425
  }
205
426
 
427
+ function renderSchemaResult(result, c) {
428
+ const out = [];
429
+ out.push('');
430
+ out.push(c.bold('TermDeck stack — Supabase schema check'));
431
+ out.push('');
432
+ if (result.skipped) {
433
+ out.push(` ${c.dim(`(skipped) ${result.reason}`)}`);
434
+ return out.join('\n');
435
+ }
436
+ if (result.connectError) {
437
+ out.push(` ${c.yellow('✗')} could not connect: ${result.connectError}`);
438
+ out.push(` ${c.dim('Check DATABASE_URL in ~/.termdeck/secrets.env, then re-run.')}`);
439
+ return out.join('\n');
440
+ }
441
+ for (const section of result.sections) {
442
+ out.push(` ${c.bold(section.name)}`);
443
+ if (section.checks.length === 0) {
444
+ out.push(` ${c.dim('(no checks ran)')}`);
445
+ continue;
446
+ }
447
+ for (const check of section.checks) {
448
+ if (check.status === 'pass') {
449
+ out.push(` ${c.green('✓')} ${check.label}`);
450
+ } else {
451
+ out.push(` ${c.yellow('✗')} ${check.label}`);
452
+ if (check.hint) {
453
+ out.push(` ${c.dim(check.hint)}`);
454
+ }
455
+ }
456
+ }
457
+ out.push('');
458
+ }
459
+ out.push(` ${result.passed}/${result.total} schema checks passed`);
460
+ return out.join('\n');
461
+ }
462
+
206
463
  async function doctor(argv) {
207
464
  const opts = parseArgv(argv);
208
465
 
@@ -223,9 +480,24 @@ async function doctor(argv) {
223
480
  })
224
481
  );
225
482
 
226
- // Exit-code priority: any network failure 2; any update available → 1;
227
- // else 0. Computed after all rows resolve so a single transient failure
228
- // doesn't mask real updates in stdout.
483
+ // Sprint 35 T3: schema check (skippable for tests / offline runs).
484
+ let schema = null;
485
+ if (!opts.noSchema) {
486
+ try {
487
+ schema = await module.exports._runSchemaCheck();
488
+ } catch (err) {
489
+ schema = {
490
+ skipped: false,
491
+ connectError: `unexpected error: ${err && err.message || err}`,
492
+ sections: [], passed: 0, total: 0, hasGaps: true,
493
+ };
494
+ }
495
+ }
496
+
497
+ // Exit-code priority: any network failure → 2; any update available OR
498
+ // schema gap → 1; else 0. Computed after all rows resolve so a single
499
+ // transient failure doesn't mask real updates in stdout. A schema connect
500
+ // error counts as 2 (same class as a registry fetch failure).
229
501
  let exitCode = 0;
230
502
  for (const r of rows) {
231
503
  if (r.status === STATUS.NETWORK_ERROR) {
@@ -234,9 +506,13 @@ async function doctor(argv) {
234
506
  }
235
507
  if (r.status === STATUS.UPDATE && exitCode < 1) exitCode = 1;
236
508
  }
509
+ if (schema && schema.connectError && exitCode < 2) exitCode = 2;
510
+ if (schema && !schema.skipped && schema.hasGaps && exitCode < 1) exitCode = 1;
237
511
 
238
512
  if (opts.json) {
239
- process.stdout.write(JSON.stringify({ exitCode, rows }, null, 2) + '\n');
513
+ const payload = { exitCode, rows };
514
+ if (schema) payload.schema = schema;
515
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
240
516
  return exitCode;
241
517
  }
242
518
 
@@ -244,6 +520,9 @@ async function doctor(argv) {
244
520
  const c = makeColors(colorEnabled);
245
521
  process.stdout.write(renderTable(rows, c) + '\n');
246
522
  process.stdout.write(renderFooter(rows, exitCode) + '\n');
523
+ if (schema) {
524
+ process.stdout.write(renderSchemaResult(schema, c) + '\n');
525
+ }
247
526
  return exitCode;
248
527
  }
249
528
 
@@ -251,5 +530,6 @@ module.exports = doctor;
251
530
  module.exports._detectInstalled = _detectInstalled;
252
531
  module.exports._fetchLatest = _fetchLatest;
253
532
  module.exports._compareSemver = _compareSemver;
533
+ module.exports._runSchemaCheck = _runSchemaCheck;
254
534
  module.exports.STACK_PACKAGES = STACK_PACKAGES;
255
535
  module.exports.STATUS = STATUS;
@@ -14,7 +14,92 @@
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
16
  const os = require('os');
17
- const { execSync } = require('child_process');
17
+ const { exec, execSync } = require('child_process');
18
+
19
+ // Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
20
+ // TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
21
+ // and continue. If it's held by something else, print a clear error and exit
22
+ // instead of letting `server.listen()` throw a generic EADDRINUSE.
23
+ // Lifted from scripts/start.sh:127–154 so npm-installed users (who never see
24
+ // start.sh) get the same recovery behavior.
25
+ function reclaimStalePort(port) {
26
+ let pids = [];
27
+ try {
28
+ const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: 'utf8' });
29
+ pids = out.split(/\s+/).filter(Boolean);
30
+ } catch (_e) {
31
+ // lsof exits 1 when no PIDs match — empty case, not an error.
32
+ pids = [];
33
+ }
34
+ if (pids.length === 0) {
35
+ // Linux fallback for systems without lsof
36
+ try {
37
+ const out = execSync(`fuser -n tcp ${port} 2>/dev/null`, { encoding: 'utf8' });
38
+ pids = out.split(/\s+/).filter((s) => /^\d+$/.test(s));
39
+ } catch (_e) { pids = []; }
40
+ }
41
+ if (pids.length === 0) return;
42
+
43
+ let isTermDeck = false;
44
+ for (const pid of pids) {
45
+ try {
46
+ const cmd = execSync(`ps -o command= -p ${pid}`, { encoding: 'utf8' });
47
+ if (/packages\/cli\/src\/index\.js/.test(cmd) || /termdeck/i.test(cmd)) {
48
+ isTermDeck = true;
49
+ break;
50
+ }
51
+ } catch (_e) { /* PID gone between lsof and ps — ignore */ }
52
+ }
53
+
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
+
72
+ console.log(` \x1b[2m[port] Reclaiming :${port} from stale TermDeck (PIDs: ${pids.join(' ')})\x1b[0m`);
73
+ for (const pid of pids) {
74
+ try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch (_e) {}
75
+ }
76
+ try { execSync('sleep 1'); } catch (_e) {}
77
+ for (const pid of pids) {
78
+ try { process.kill(parseInt(pid, 10), 'SIGKILL'); } catch (_e) {}
79
+ }
80
+ } else {
81
+ console.error(`\n \x1b[31m✗ Port ${port} is in use by a non-TermDeck process (PIDs: ${pids.join(' ')})\x1b[0m`);
82
+ console.error(` \x1b[2mTry a different port: termdeck --port ${port + 1}\x1b[0m\n`);
83
+ process.exit(1);
84
+ }
85
+ }
86
+
87
+ // Sprint 35 T4: transcript-table-missing hint. If DATABASE_URL is set and
88
+ // psql is on PATH, probe for termdeck_transcripts. Fire-and-forget so a slow
89
+ // network round-trip to Supabase never blocks boot. Lifted from
90
+ // scripts/start.sh:309–313.
91
+ function checkTranscriptTableHint(databaseUrl) {
92
+ if (!databaseUrl) return;
93
+ try { execSync('command -v psql', { stdio: 'ignore' }); } catch (_e) { return; }
94
+ exec('psql "$DATABASE_URL" -c "SELECT 1 FROM termdeck_transcripts LIMIT 0"', {
95
+ env: { ...process.env, DATABASE_URL: databaseUrl },
96
+ timeout: 5000,
97
+ }, (err) => {
98
+ if (err) {
99
+ console.log(` \x1b[33m[hint]\x1b[0m Transcript backup table missing. Run: \x1b[1mtermdeck doctor\x1b[0m (or psql $DATABASE_URL -f config/transcript-migration.sql)`);
100
+ }
101
+ });
102
+ }
18
103
 
19
104
  // Parse CLI args
20
105
  const args = process.argv.slice(2);
@@ -130,7 +215,7 @@ for (let i = 0; i < args.length; i++) {
130
215
  termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
131
216
  termdeck init --rumen Deploy Tier 3 async learning (Rumen)
132
217
  termdeck forge Generate Claude skills from memories (experimental)
133
- termdeck doctor Check whether the stack packages are up to date
218
+ termdeck doctor Diagnose stack npm versions + Supabase schema (use --no-schema to skip the DB probe)
134
219
 
135
220
  Keyboard shortcuts (in browser):
136
221
  Ctrl+Shift+N Focus prompt bar
@@ -164,6 +249,7 @@ const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yam
164
249
 
165
250
  const config = loadConfig();
166
251
  if (flags.port) config.port = flags.port;
252
+ else if (process.env.TERMDECK_PORT) config.port = parseInt(process.env.TERMDECK_PORT, 10);
167
253
  if (flags.sessionLogs) {
168
254
  config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
169
255
  console.log('[cli] session logs enabled — writing to ~/.termdeck/sessions/ on panel exit');
@@ -174,6 +260,11 @@ const port = config.port || 3000;
174
260
  const host = config.host || '127.0.0.1';
175
261
  const url = `http://${host}:${port}`;
176
262
 
263
+ // Sprint 35 T4: reclaim the port if a previous TermDeck is squatting on it,
264
+ // or hard-stop with a useful hint if a non-TermDeck process holds it. Runs
265
+ // before server.listen() so EADDRINUSE never bubbles up.
266
+ reclaimStalePort(port);
267
+
177
268
  // Bind guardrail: refuse non-loopback without auth token
178
269
  const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
179
270
  if (!LOOPBACK.has(host)) {
@@ -189,17 +280,20 @@ if (!LOOPBACK.has(host)) {
189
280
  // Sprint 25 T4: non-blocking nudge when RAG is configured but the Supabase MCP
190
281
  // (T1's `@supabase/mcp-server-supabase` detection) isn't installed. Lazy-loads
191
282
  // T1's module so Tier 1 users with no RAG never pay the require cost. Silent
192
- // when RAG is off, when the MCP is detected, when ~/.claude/mcp.json already
193
- // declares a `supabase` server, or when anything below throws.
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.
194
290
  async function checkSupabaseMcpHint(cfg) {
195
291
  if (!cfg || !cfg.rag || cfg.rag.enabled !== true) return null;
196
292
  try {
197
- const claudeMcpPath = path.join(os.homedir(), '.claude', 'mcp.json');
198
- if (fs.existsSync(claudeMcpPath)) {
199
- try {
200
- const parsed = JSON.parse(fs.readFileSync(claudeMcpPath, 'utf8'));
201
- if (parsed && parsed.mcpServers && parsed.mcpServers.supabase) return null;
202
- } 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;
203
297
  }
204
298
  const { detectMcp } = require(path.join(__dirname, '..', '..', 'server', 'src', 'setup', 'supabase-mcp.js'));
205
299
  const result = await detectMcp();
@@ -229,10 +323,23 @@ server.listen(port, host, async () => {
229
323
  ╚══════════════════════════════════════╝
230
324
  `);
231
325
 
326
+ // Sprint 35 T4: RAG state line. Always-visible indicator of what mode the
327
+ // user is in — MCP-only (the new default after Sprint 35 T1) or full RAG
328
+ // writing to mnestra_*_memory tables. Dim line, single sentence.
329
+ if (config.rag && config.rag.enabled === true) {
330
+ console.log(` \x1b[2mRAG: on — events syncing to mnestra_session_memory / mnestra_project_memory / mnestra_developer_memory\x1b[0m\n`);
331
+ } else {
332
+ console.log(` \x1b[2mRAG: off (MCP-only mode) — toggle in dashboard at ${url}/#config to enable session/project/developer memory tables\x1b[0m\n`);
333
+ }
334
+
232
335
  if (firstRun) {
233
336
  console.log(" First run detected. Open http://localhost:3000 and click 'config' to set up.\n");
234
337
  }
235
338
 
339
+ // Sprint 35 T4: probe Supabase for the transcript backup table; print a
340
+ // hint if it's missing. Non-blocking — the result lands after the banner.
341
+ checkTranscriptTableHint(process.env.DATABASE_URL || (config.rag && config.rag.databaseUrl));
342
+
236
343
  // Run preflight health checks (non-blocking — warn but don't prevent startup)
237
344
  runPreflight(config).then((result) => {
238
345
  printHealthBanner(result);