@jhizzard/termdeck 1.0.11 → 1.0.13

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
@@ -1,5 +1,7 @@
1
1
  # TermDeck
2
2
 
3
+ [![Install Smoke](https://github.com/jhizzard/termdeck/workflows/install-smoke/badge.svg)](https://github.com/jhizzard/termdeck/actions/workflows/install-smoke.yml)
4
+
3
5
  > **The terminal that remembers what you fixed last month.**
4
6
 
5
7
  A browser-based terminal multiplexer with an onboarding tour, rich per-panel metadata, and **Flashback** — automatic recall of similar past errors, surfaced the moment a panel hits a problem. No asking, no querying, no manual search. TermDeck notices you're stuck and offers the memory.
@@ -16,6 +18,8 @@ npx @jhizzard/termdeck
16
18
 
17
19
  Ninety seconds, one command. Node 18+ is all you need — prebuilt binaries mean no C++ toolchain. Your browser opens automatically at `http://127.0.0.1:3000`, an onboarding tour walks you through every button, and you're launching real PTY shells, Claude Code, Python servers, or anything else a normal terminal can run.
18
20
 
21
+ > **Linux x64 — one-line caveat.** If `npm config get omit` returns `optional`, append `--include=optional` to any global TermDeck or `@anthropic-ai/claude-code` install (`npm install -g @jhizzard/termdeck --include=optional`). On macOS this is unnecessary. Full explanation in [docs/GETTING-STARTED.md § Linux x64 install hint](docs/GETTING-STARTED.md#linux-x64-install-hint).
22
+
19
23
  This is **Tier 1**. Works immediately, fully local, no accounts, no credentials, no database. You get the full dashboard — 7 grid layouts, 8 themes, per-panel metadata overlays, terminal switcher, reply button, status logs, session history in local SQLite. **Flashback is silent at this tier** because there's no memory store to query.
20
24
 
21
25
  First-time user? The **config** button in the toolbar shows what's set up and what's next — click it for a live view of each tier's status with guided next steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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"
@@ -160,6 +160,32 @@ function _compareSemver(a, b) {
160
160
  return 0;
161
161
  }
162
162
 
163
+ // Sprint 58 T2 (Brad #4) — Mnestra-version probe used to gate the hybrid-
164
+ // search RPC name in `_runSchemaCheck`. Mnestra ≤ 0.3.x exposes
165
+ // `search_memories(...)`; Mnestra ≥ 0.4.0 renamed it to
166
+ // `memory_hybrid_search(...)`. Pre-fix doctor hard-coded `search_memories`,
167
+ // false-flagging RED on every install at Mnestra 0.4.0+. Reuses
168
+ // `_detectInstalled` (the same `npm ls -g` probe the version-check section
169
+ // already runs). Returns the installed version string or null if not
170
+ // detectable. Exposed as its own module export so unit tests can monkey-
171
+ // patch it independently of the rest of the doctor pipeline.
172
+ async function _detectMnestraVersion() {
173
+ return module.exports._detectInstalled('@jhizzard/mnestra');
174
+ }
175
+
176
+ // Sprint 58 T2 (Brad #4) — RPC names to probe for the Mnestra hybrid-search
177
+ // function, gated on the installed Mnestra version.
178
+ // ≥ 0.4.0 → ['memory_hybrid_search']
179
+ // ≤ 0.3.x → ['search_memories']
180
+ // null/unknown → ['memory_hybrid_search', 'search_memories'] (probe both;
181
+ // GREEN if either exists — graceful on offline / non-
182
+ // globally-installed cases).
183
+ function _selectHybridSearchRpcNames(mnestraVersion) {
184
+ if (!mnestraVersion) return ['memory_hybrid_search', 'search_memories'];
185
+ if (_compareSemver(mnestraVersion, '0.4.0') >= 0) return ['memory_hybrid_search'];
186
+ return ['search_memories'];
187
+ }
188
+
163
189
  function classifyRow(installed, latest) {
164
190
  if (latest === null) return STATUS.NETWORK_ERROR;
165
191
  if (installed === null) return STATUS.NOT_INSTALLED;
@@ -354,13 +380,45 @@ async function _runSchemaCheck(opts = {}) {
354
380
  status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
355
381
  hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
356
382
  });
357
- for (const fn of ['match_memories', 'search_memories', 'memory_status_aggregation']) {
358
- modern.push({
359
- label: `${fn}() RPC`,
360
- status: (await probeSchema(client, SCHEMA_QUERIES.rpc(fn))) ? 'pass' : 'fail',
361
- hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
362
- });
383
+ modern.push({
384
+ label: `match_memories() RPC`,
385
+ status: (await probeSchema(client, SCHEMA_QUERIES.rpc('match_memories'))) ? 'pass' : 'fail',
386
+ hint: `migration 005/006 creates it re-run: termdeck init --mnestra --yes`,
387
+ });
388
+
389
+ // Sprint 58 T2 (Brad #4): version-gate the hybrid-search RPC name. The
390
+ // function was renamed `search_memories` → `memory_hybrid_search` at
391
+ // Mnestra 0.4.0. Pre-fix doctor probed only the legacy name, so every
392
+ // install at Mnestra ≥ 0.4.0 reported false-RED here. The version is
393
+ // passed in by `doctor()` (which already detected it for the version-
394
+ // check table); when called standalone, we self-detect.
395
+ const mnestraVersion = optsObj.mnestraVersion !== undefined
396
+ ? optsObj.mnestraVersion
397
+ : await module.exports._detectMnestraVersion();
398
+ const hybridProbeNames = _selectHybridSearchRpcNames(mnestraVersion);
399
+ const hybridProbeLabel = hybridProbeNames.length === 1
400
+ ? `${hybridProbeNames[0]}() RPC`
401
+ : `${hybridProbeNames.join(' or ')}() RPC`;
402
+ let hybridOk = false;
403
+ for (const name of hybridProbeNames) {
404
+ if (await probeSchema(client, SCHEMA_QUERIES.rpc(name))) {
405
+ hybridOk = true;
406
+ break;
407
+ }
363
408
  }
409
+ modern.push({
410
+ label: hybridProbeLabel,
411
+ status: hybridOk ? 'pass' : 'fail',
412
+ hint: mnestraVersion
413
+ ? `Mnestra ${mnestraVersion} expects ${hybridProbeNames[0]}() — re-run: termdeck init --mnestra --yes`
414
+ : `migration 005 (legacy) or 015+ (modern) creates it — re-run: termdeck init --mnestra --yes`,
415
+ });
416
+
417
+ modern.push({
418
+ label: `memory_status_aggregation() RPC`,
419
+ status: (await probeSchema(client, SCHEMA_QUERIES.rpc('memory_status_aggregation'))) ? 'pass' : 'fail',
420
+ hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
421
+ });
364
422
 
365
423
  // Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
366
424
  const legacy = sections[1].checks;
@@ -510,10 +568,15 @@ async function doctor(argv) {
510
568
  );
511
569
 
512
570
  // Sprint 35 T3: schema check (skippable for tests / offline runs).
571
+ // Sprint 58 T2 (Brad #4): pass the already-detected Mnestra version
572
+ // through so `_runSchemaCheck` doesn't re-shell-out to `npm ls -g`. The
573
+ // version-check section detected it a few lines up; reuse it.
513
574
  let schema = null;
514
575
  if (!opts.noSchema) {
576
+ const mnestraRow = rows.find((r) => r.package === '@jhizzard/mnestra');
577
+ const mnestraVersion = mnestraRow ? mnestraRow.installed : null;
515
578
  try {
516
- schema = await module.exports._runSchemaCheck();
579
+ schema = await module.exports._runSchemaCheck({ mnestraVersion });
517
580
  } catch (err) {
518
581
  schema = {
519
582
  skipped: false,
@@ -559,6 +622,8 @@ module.exports = doctor;
559
622
  module.exports._detectInstalled = _detectInstalled;
560
623
  module.exports._fetchLatest = _fetchLatest;
561
624
  module.exports._compareSemver = _compareSemver;
625
+ module.exports._detectMnestraVersion = _detectMnestraVersion;
626
+ module.exports._selectHybridSearchRpcNames = _selectHybridSearchRpcNames;
562
627
  module.exports._runSchemaCheck = _runSchemaCheck;
563
628
  module.exports.STACK_PACKAGES = STACK_PACKAGES;
564
629
  module.exports.STATUS = STATUS;
@@ -14,7 +14,93 @@
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
16
  const os = require('os');
17
- const { exec, execSync } = require('child_process');
17
+ const { exec, execSync, spawn } = require('child_process');
18
+
19
+ // Sprint 59 — Brad #1 nohup-secrets bootstrap.
20
+ //
21
+ // Brad's environment: `nohup termdeck --no-stack ...` from a shell that has
22
+ // NOT sourced ~/.termdeck/secrets.env. In-process `setenv()` (which Node's
23
+ // loadSecretsEnv() uses) updates libc's `environ` pointer but does NOT
24
+ // propagate to /proc/<pid>/environ on Linux glibc — the kernel reads from
25
+ // the env_start..env_end memory range fixed at execve() time, and new keys
26
+ // added via setenv() get heap-allocated outside that range. A probe that
27
+ // introspects /proc therefore sees the empty initial env.
28
+ //
29
+ // Fix: when launched in non-TTY mode (nohup detaches stdin/stdout/stderr)
30
+ // AND secrets.env exists with at least one key not already in process.env,
31
+ // spawn a detached child node with the merged env and exit the parent. The
32
+ // child's /proc/<pid>/environ contains the merged keys because spawn() goes
33
+ // through fork+execve(), which sets the kernel env range with the new env.
34
+ //
35
+ // Guards (must ALL be true to spawn-and-exit):
36
+ // 1. __TERMDECK_BOOTSTRAPPED env marker absent (we're the original entry,
37
+ // not the re-execed child).
38
+ // 2. argv[0] is NOT a subcommand we hand off (`init`, `forge`, `doctor`,
39
+ // `stack`). Those subcommands have their own env-loading paths
40
+ // (init's --from-env, doctor's dotenv-io reader, stack's loadSecrets)
41
+ // and run interactively under piped stdio in tests / CI. Brad's bug
42
+ // is specifically the default server-launch path; bootstrap there.
43
+ // 3. neither stdout nor stderr is a TTY (interactive `termdeck` keeps the
44
+ // legacy in-process loadSecretsEnv path so Ctrl+C / signal handling /
45
+ // user-visible boot output stay attached to the user's terminal).
46
+ // 4. argv does NOT include --service or --non-interactive (T2 owns those
47
+ // flags for systemd Type=simple; that path runs in foreground so
48
+ // systemd's cgroup-tracked main process stays alive).
49
+ // 5. ~/.termdeck/secrets.env exists.
50
+ // 6. parsing the file yields at least one key that is NOT already in
51
+ // process.env (don't clobber pre-set shell vars; user env wins).
52
+ function maybeBootstrapAndDetach() {
53
+ if (process.env.__TERMDECK_BOOTSTRAPPED === '1') {
54
+ delete process.env.__TERMDECK_BOOTSTRAPPED;
55
+ return false;
56
+ }
57
+ const argv = process.argv.slice(2);
58
+ const SKIP_SUBCOMMANDS = new Set(['init', 'forge', 'doctor', 'stack']);
59
+ if (argv.length > 0 && SKIP_SUBCOMMANDS.has(argv[0])) return false;
60
+ if (process.stdout.isTTY || process.stderr.isTTY) return false;
61
+ const argvSet = new Set(argv);
62
+ if (argvSet.has('--service') || argvSet.has('--non-interactive')) return false;
63
+ const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
64
+ if (!fs.existsSync(secretsPath)) return false;
65
+
66
+ let raw;
67
+ try { raw = fs.readFileSync(secretsPath, 'utf-8'); }
68
+ catch (_e) { return false; }
69
+
70
+ const merged = {};
71
+ for (const line of raw.split(/\r?\n/)) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed || trimmed.startsWith('#')) continue;
74
+ const eq = trimmed.indexOf('=');
75
+ if (eq === -1) continue;
76
+ const key = trimmed.slice(0, eq).trim();
77
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) continue;
78
+ let val = trimmed.slice(eq + 1).trim();
79
+ if (val.length >= 2 && (val[0] === '"' || val[0] === "'") && val[val.length - 1] === val[0]) {
80
+ val = val.slice(1, -1);
81
+ }
82
+ // Sprint 59 T4-CODEX residual fix: file value fills when parent env is undefined
83
+ // OR empty string (Brad's actual failure shape includes parent env present-but-blank,
84
+ // not only missing entirely). Non-empty parent env still wins.
85
+ if (process.env[key] === undefined || process.env[key] === '') merged[key] = val;
86
+ }
87
+ if (Object.keys(merged).length === 0) return false;
88
+
89
+ const env = { ...process.env, ...merged, __TERMDECK_BOOTSTRAPPED: '1' };
90
+ const child = spawn(process.execPath, [__filename, ...process.argv.slice(2)], {
91
+ env,
92
+ stdio: 'inherit',
93
+ detached: true,
94
+ });
95
+ child.unref();
96
+ // Parent exits immediately. The fixture's TD_PID points at the parent
97
+ // process; once the parent dies, /proc/<TD_PID>/environ becomes unreadable
98
+ // and the fixture's pgrep fallback finds the child (which has the merged
99
+ // env in its /proc/<pid>/environ via execve). The child keeps running.
100
+ process.exit(0);
101
+ }
102
+
103
+ maybeBootstrapAndDetach();
18
104
 
19
105
  // Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
20
106
  // TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
@@ -215,9 +301,24 @@ const noStackIdx = args.indexOf('--no-stack');
215
301
  const noStackRequested = noStackIdx !== -1;
216
302
  if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
217
303
 
304
+ // Sprint 59 T2 — Brad #7: --service / --non-interactive flag for systemd
305
+ // Type=simple deployment. When set, the launcher must (a) skip browser
306
+ // auto-open (no DISPLAY in service contexts), (b) bypass the auto-orchestrate
307
+ // child-spawn detour so ExecStart=/usr/local/bin/termdeck --service blocks
308
+ // for the lifetime of the server (Type=simple sees an active foreground
309
+ // process), (c) be tolerated everywhere `--no-stack` is. Strip both aliases
310
+ // repeatedly so duplicates don't survive into the flag-parsing loop.
311
+ let serviceMode = false;
312
+ while (true) {
313
+ const idx = args.findIndex((a) => a === '--service' || a === '--non-interactive');
314
+ if (idx === -1) break;
315
+ serviceMode = true;
316
+ args.splice(idx, 1);
317
+ }
318
+
218
319
  const wantsHelp = args.includes('--help') || args.includes('-h');
219
320
 
220
- if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !wantsHelp && shouldAutoOrchestrate()) {
321
+ if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !serviceMode && !wantsHelp && shouldAutoOrchestrate()) {
221
322
  const stack = require(path.join(__dirname, 'stack.js'));
222
323
  stack(args).then((code) => process.exit(code || 0)).catch((err) => {
223
324
  console.error('[cli] auto-stack failed:', err && err.stack || err);
@@ -243,6 +344,8 @@ for (let i = 0; i < args.length; i++) {
243
344
  termdeck Auto-orchestrate stack if configured, else Tier-1-only
244
345
  termdeck stack Force boot Mnestra + check Rumen + start TermDeck
245
346
  termdeck --no-stack Skip orchestrator (force Tier-1-only boot)
347
+ termdeck --service Non-interactive foreground mode for systemd Type=simple
348
+ (alias: --non-interactive; implies --no-stack + --no-open)
246
349
  termdeck --port 8080 Start on custom port
247
350
  termdeck --no-open Don't auto-open browser
248
351
  termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
@@ -277,6 +380,16 @@ if (flags.sessionLogs) {
277
380
  process.env.TERMDECK_SESSION_LOGS = '1';
278
381
  }
279
382
 
383
+ // Sprint 59 T2 — Brad #7: --service implies --no-open. The browser auto-open
384
+ // path runs `xdg-open` / `open` which has no meaning under systemd (no
385
+ // DISPLAY) and would just dump a non-fatal error to stderr/journalctl every
386
+ // boot. Honoring serviceMode here is in addition to the auto-orchestrate
387
+ // bypass above — a user could pass `--no-stack --service` and we still want
388
+ // noOpen to win.
389
+ if (serviceMode) {
390
+ flags.noOpen = true;
391
+ }
392
+
280
393
  // First-run detection (Sprint 19 T3): surface a one-line hint pointing at
281
394
  // the setup wizard when no config.yaml exists yet. Check happens before
282
395
  // loadConfig() so the message reflects on-disk state, not defaults.
@@ -119,11 +119,20 @@ function parseFlags(argv) {
119
119
  function inputsFromEnv() {
120
120
  const env = process.env;
121
121
  const missing = [];
122
+ // Brad #2 (Sprint 59): strip surrounding matched quotes from each value
123
+ // BEFORE shape-checks. The dotenv parsers (config.js / dotenv-io.js /
124
+ // launcher.js readSecrets) all strip at file-read time, but `--from-env`
125
+ // bypasses those — Brad's reproducer exports a literal-quoted DATABASE_URL
126
+ // directly into the shell's environment via
127
+ // export DATABASE_URL="\"$TEST_DATABASE_URL\""
128
+ // and the leading `"` makes new URL() throw 'Invalid URL'. Strip here at
129
+ // the validator boundary so a quoted env-var value gets the same handling
130
+ // as a quoted secrets.env line.
122
131
  const required = {
123
- SUPABASE_URL: env.SUPABASE_URL,
124
- SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY,
125
- DATABASE_URL: env.DATABASE_URL,
126
- OPENAI_API_KEY: env.OPENAI_API_KEY
132
+ SUPABASE_URL: urlHelper.stripSurroundingQuotes((env.SUPABASE_URL || '').trim()),
133
+ SUPABASE_SERVICE_ROLE_KEY: urlHelper.stripSurroundingQuotes((env.SUPABASE_SERVICE_ROLE_KEY || '').trim()),
134
+ DATABASE_URL: urlHelper.stripSurroundingQuotes((env.DATABASE_URL || '').trim()),
135
+ OPENAI_API_KEY: urlHelper.stripSurroundingQuotes((env.OPENAI_API_KEY || '').trim())
127
136
  };
128
137
  for (const [k, v] of Object.entries(required)) {
129
138
  if (!v || !v.trim()) missing.push(k);
@@ -155,7 +164,7 @@ function inputsFromEnv() {
155
164
  const oaErr = urlHelper.looksLikeOpenAiKey(required.OPENAI_API_KEY);
156
165
  if (oaErr) throw new Error(`OPENAI_API_KEY: ${oaErr}`);
157
166
 
158
- const anthropicKey = (env.ANTHROPIC_API_KEY || '').trim() || null;
167
+ const anthropicKey = urlHelper.stripSurroundingQuotes((env.ANTHROPIC_API_KEY || '').trim()) || null;
159
168
  if (anthropicKey) {
160
169
  const aErr = urlHelper.looksLikeAnthropicKey(anthropicKey);
161
170
  if (aErr) {
@@ -432,7 +432,7 @@ async function checkRumen() {
432
432
 
433
433
  // ── Step 4: TermDeck ────────────────────────────────────────────────
434
434
 
435
- function execTermDeck({ port, extra }) {
435
+ function execTermDeck({ port, extra }, deps = {}) {
436
436
  // Spawn a fresh node process for the CLI rather than require()-ing it
437
437
  // in-process. Two reasons:
438
438
  // 1. require() hits Node's module cache after stack.js → index.js →
@@ -443,22 +443,45 @@ function execTermDeck({ port, extra }) {
443
443
  // stack.js tried to re-require the (cached) CLI — silent exit.
444
444
  // 2. Pass --no-stack on the way back so index.js definitively skips
445
445
  // the auto-orchestrate detection. Defensive even with the spawn.
446
+ //
447
+ // Sprint 59 T2 — Brad #7: returns Promise<exitCode> that resolves when the
448
+ // child exits. Pre-Sprint-59 the function returned undefined synchronously,
449
+ // so main() returned 0 immediately and `process.exit(0)` fired before the
450
+ // child had bound the port. Under `systemd Type=simple`, ExecStart=
451
+ // /usr/local/bin/termdeck "succeeded" in milliseconds and the cgroup tore
452
+ // down the orphaned child — service stuck inactive even though everything
453
+ // looked fine to the operator. The await in main() now blocks ExecStart
454
+ // for the lifetime of the child server. `deps` exposes spawn + signals as
455
+ // injection points for tests/launcher-service-flag.test.js.
456
+ const _spawn = deps.spawn || spawn;
457
+ const _signals = deps.signals || process;
446
458
  const cliPath = path.join(__dirname, 'index.js');
447
459
  const argv = [cliPath, '--no-stack'];
448
460
  if (port) argv.push('--port', String(port));
449
461
  argv.push(...extra);
450
- const child = spawn(process.execPath, argv, {
462
+ const child = _spawn(process.execPath, argv, {
451
463
  stdio: 'inherit',
452
464
  env: process.env,
453
465
  });
454
- child.on('exit', (code, signal) => {
455
- if (signal) process.kill(process.pid, signal);
456
- else process.exit(code == null ? 0 : code);
457
- });
458
- // Forward Ctrl+C cleanly so the spawned server can shut down.
466
+ // Forward Ctrl+C / systemd-stop signals cleanly so the child can flush
467
+ // before exit. Listeners are attached to the parent's signal source so
468
+ // tests can simulate signal delivery without raising real signals.
459
469
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
460
- process.on(sig, () => { try { child.kill(sig); } catch (_e) { /* gone */ } });
470
+ _signals.on(sig, () => { try { child.kill(sig); } catch (_e) { /* gone */ } });
461
471
  }
472
+ return new Promise((resolve) => {
473
+ child.on('exit', (code, signal) => {
474
+ if (signal) {
475
+ // Re-raise the signal on the parent so the caller (systemd / shell)
476
+ // sees the right termination state, then resolve so main() can
477
+ // unwind.
478
+ try { _signals.kill(_signals.pid, signal); } catch (_e) { /* fallthrough */ }
479
+ resolve(code == null ? 0 : code);
480
+ return;
481
+ }
482
+ resolve(code == null ? 0 : code);
483
+ });
484
+ });
462
485
  }
463
486
 
464
487
  // ── Main ────────────────────────────────────────────────────────────
@@ -514,8 +537,11 @@ async function main(rawArgs) {
514
537
  stepLine('4/4', 'Starting TermDeck', 'BOOT', `(port ${port})`);
515
538
  process.stdout.write(`\n ${ANSI.bold}Stack:${ANSI.reset} ${ANSI.green}${summary.join(' | ')}${ANSI.reset}\n\n`);
516
539
 
517
- execTermDeck({ port, extra: args.extra });
518
- return 0;
540
+ // Sprint 59 T2 — Brad #7: await the child so ExecStart blocks for the
541
+ // child's full lifetime. Without the await, `Type=simple` units saw
542
+ // ExecStart return 0 in milliseconds and tore the service down.
543
+ const exitCode = await execTermDeck({ port, extra: args.extra });
544
+ return exitCode;
519
545
  }
520
546
 
521
547
  module.exports = function (argv) {
@@ -532,3 +558,6 @@ module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
532
558
  module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
533
559
  module.exports.CLAUDE_MCP_PATHS = CLAUDE_MCP_PATHS;
534
560
  module.exports.hasMnestraMcpEntry = hasMnestraMcpEntry;
561
+ // Sprint 59 T2 — Brad #7: exposed for tests/launcher-service-flag.test.js so
562
+ // the wait-semantics fix can be verified without a real Node child spawn.
563
+ module.exports._execTermDeck = execTermDeck;
@@ -177,6 +177,15 @@
177
177
  panel.addEventListener('drop', (e) => {
178
178
  e.preventDefault();
179
179
  panel.classList.remove('drag-over');
180
+ panel.classList.remove('file-drop-active');
181
+ // External file drop (zip / image / any binary) → upload + type @path.
182
+ // Detected when dataTransfer.files has entries and there's no internal panel drag.
183
+ const files = e.dataTransfer && e.dataTransfer.files;
184
+ const hasInternalDrag = !!document.querySelector('.term-panel.dragging');
185
+ if (!hasInternalDrag && files && files.length > 0) {
186
+ uploadFilesAndType(panel, Array.from(files));
187
+ return;
188
+ }
180
189
  const draggedId = (() => {
181
190
  try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
182
191
  })();
@@ -188,6 +197,82 @@
188
197
  const dropAfter = (e.clientX - rect.left) > rect.width / 2;
189
198
  panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
190
199
  });
200
+
201
+ // Sprint 59 scope-expansion (Brad's "drop a zip into Codex" question 2026-05-07):
202
+ // file drop and clipboard image paste upload to /api/sessions/:id/upload, then type
203
+ // @<path> via the existing /input endpoint so the agent (Claude/Codex/Gemini/Grok)
204
+ // sees the standard @filepath attachment syntax.
205
+ panel.addEventListener('dragover', (e) => {
206
+ const types = (e.dataTransfer && e.dataTransfer.types) || [];
207
+ const hasFiles = Array.from(types).includes('Files');
208
+ if (!hasFiles) return;
209
+ e.preventDefault();
210
+ try { e.dataTransfer.dropEffect = 'copy'; } catch (_e) {}
211
+ panel.classList.add('file-drop-active');
212
+ });
213
+
214
+ panel.addEventListener('dragleave', (e) => {
215
+ if (!panel.contains(e.relatedTarget)) panel.classList.remove('file-drop-active');
216
+ });
217
+
218
+ panel.addEventListener('paste', (e) => {
219
+ const items = (e.clipboardData && e.clipboardData.items) || [];
220
+ const blobs = [];
221
+ for (const item of items) {
222
+ if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
223
+ const blob = item.getAsFile();
224
+ if (blob) blobs.push(blob);
225
+ }
226
+ }
227
+ if (blobs.length === 0) return;
228
+ e.preventDefault();
229
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
230
+ const named = blobs.map((b, i) => {
231
+ const ext = (b.type.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
232
+ const name = b.name && b.name.length > 0
233
+ ? b.name
234
+ : `pasted-${ts}${blobs.length > 1 ? '-' + i : ''}.${ext}`;
235
+ return new File([b], name, { type: b.type });
236
+ });
237
+ uploadFilesAndType(panel, named);
238
+ });
239
+ }
240
+
241
+ async function uploadFilesAndType(panel, files) {
242
+ const sessionId = panel.id.replace(/^panel-/, '');
243
+ for (const file of files) {
244
+ try {
245
+ const url = `/api/sessions/${sessionId}/upload?name=${encodeURIComponent(file.name)}`;
246
+ const buf = await file.arrayBuffer();
247
+ const res = await fetch(url, {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/octet-stream' },
250
+ credentials: 'same-origin',
251
+ body: buf,
252
+ });
253
+ if (!res.ok) {
254
+ const errText = await res.text().catch(() => '');
255
+ console.error('[upload] failed', res.status, errText);
256
+ continue;
257
+ }
258
+ const data = await res.json();
259
+ // Type "@<path> " into the panel via the existing /input endpoint so the
260
+ // shape matches a manually-typed @filepath. The trailing space lets the
261
+ // user keep typing the rest of their prompt.
262
+ const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ credentials: 'same-origin',
266
+ body: JSON.stringify({ text: `@${data.path} `, source: 'file-drop' }),
267
+ });
268
+ if (!inputRes.ok) {
269
+ const errText = await inputRes.text().catch(() => '');
270
+ console.error('[upload] file uploaded but typing failed', inputRes.status, errText);
271
+ }
272
+ } catch (err) {
273
+ console.error('[upload] exception', err);
274
+ }
275
+ }
191
276
  }
192
277
 
193
278
  // ===== Create Terminal Panel =====
@@ -2825,6 +2910,63 @@
2825
2910
  }
2826
2911
  }
2827
2912
 
2913
+ // Sprint 57 T2 — post-resize layout-health assertion + forced reflow.
2914
+ // Sprint 55 T2 saw rapid Playwright resize chains crush #termGrid into
2915
+ // the corner with no manual recovery. Codex T4-SWEEP-CELLS audit was
2916
+ // explicit: the right shape is a health check + forced reflow at the
2917
+ // tail of the existing debounced fitAll(), not a second window-resize
2918
+ // listener. Reentrancy guarded so a degenerate state can't loop.
2919
+ function verifyLayoutHealth() {
2920
+ const grid = document.getElementById('termGrid');
2921
+ if (!grid) return;
2922
+ if (verifyLayoutHealth._inFlight) return;
2923
+ const rect = grid.getBoundingClientRect();
2924
+ // The grid spans the viewport horizontally (topbar is above it; the
2925
+ // guide-rail is fixed-position overlay reserved by 38px right padding,
2926
+ // not a flex sibling). A healthy grid's getBoundingClientRect().width
2927
+ // tracks window.innerWidth modulo body margins. Flag if it shrinks
2928
+ // below 90% of the usable viewport (briefed threshold; T4-CODEX
2929
+ // 14:12 ET audit confirms 90% is the spec, not the looser 85%).
2930
+ const viewportW = window.innerWidth || document.documentElement.clientWidth || 0;
2931
+ const gridUnderwidth = viewportW > 0 && rect.width < viewportW * 0.90;
2932
+ // Each visible terminal panel must have positive width AND height.
2933
+ // Skip panels intentionally hidden by layout (control mode CSS-hides
2934
+ // .term-panel via `display:none`; layout-focus hides non-focused).
2935
+ let panelDegenerate = false;
2936
+ let panelDegenerateId = null;
2937
+ for (const [sid, entry] of state.sessions) {
2938
+ if (!entry || !entry.el) continue;
2939
+ const style = window.getComputedStyle(entry.el);
2940
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
2941
+ const r = entry.el.getBoundingClientRect();
2942
+ if (r.width <= 0 || r.height <= 0) {
2943
+ panelDegenerate = true;
2944
+ panelDegenerateId = sid;
2945
+ break;
2946
+ }
2947
+ }
2948
+ if (!gridUnderwidth && !panelDegenerate) return;
2949
+ verifyLayoutHealth._inFlight = true;
2950
+ console.warn(
2951
+ '[client] layout health check failed (gridUnderwidth=' + gridUnderwidth
2952
+ + ', panelDegenerate=' + panelDegenerate
2953
+ + (panelDegenerateId ? ', sid=' + panelDegenerateId : '')
2954
+ + ') — forcing recovery'
2955
+ );
2956
+ // Recovery: detach + reapply the current layout class to force the
2957
+ // CSS Grid templates to recompute, then refit all panels. Two RAFs so
2958
+ // the browser commits the className=''→className=cls round-trip.
2959
+ requestAnimationFrame(() => {
2960
+ const cls = grid.className;
2961
+ grid.className = '';
2962
+ void grid.offsetHeight; // force synchronous reflow
2963
+ grid.className = cls;
2964
+ requestAnimationFrame(() => {
2965
+ try { fitAll(); } finally { verifyLayoutHealth._inFlight = false; }
2966
+ });
2967
+ });
2968
+ }
2969
+
2828
2970
  // Debounce: collapse a burst of calls (e.g. a window-resize drag firing
2829
2971
  // dozens of events/sec) into a single invocation after `wait` ms of quiet.
2830
2972
  function debounce(fn, wait) {
@@ -2836,7 +2978,13 @@
2836
2978
  }
2837
2979
 
2838
2980
  const fitAllDebounced = debounce(() => {
2839
- requestAnimationFrame(() => fitAll());
2981
+ requestAnimationFrame(() => {
2982
+ fitAll();
2983
+ // Sprint 57 T2 — post-fit layout-health probe (~250 ms after fit so
2984
+ // the browser has committed the resize). Extends the existing window
2985
+ // resize listener (no second listener added).
2986
+ setTimeout(verifyLayoutHealth, 250);
2987
+ });
2840
2988
  }, 100);
2841
2989
 
2842
2990
  // ===== ONBOARDING TOUR =====
@@ -3444,18 +3592,29 @@
3444
3592
  // Topbar RAG indicator. The #stat-rag stub in index.html was hidden by
3445
3593
  // Sprint 9 T2; re-purpose it as a live state line so users can see, at a
3446
3594
  // glance, what the toggle is doing without opening Settings each time.
3595
+ //
3596
+ // Sprint 57 T2 (F-T2-2 + F-T2-6) — consumes the server-derived `ragMode`
3597
+ // enum directly instead of re-deriving from `ragEnabled` + `ragConfigEnabled`
3598
+ // booleans. The single source of truth lives in `packages/server/src/rag-mode.js`.
3599
+ // Falls back to legacy boolean derivation for older servers (pre-Sprint-57)
3600
+ // during a rolling upgrade.
3447
3601
  function updateRagIndicator() {
3448
3602
  const el = document.getElementById('stat-rag');
3449
3603
  if (!el) return;
3450
3604
  const cfg = state.config || {};
3451
- const intent = !!cfg.ragConfigEnabled;
3452
- const effective = !!cfg.ragEnabled;
3605
+ let mode = cfg.ragMode;
3606
+ if (!mode) {
3607
+ // Pre-Sprint-57 server fallback — replicate the legacy derivation.
3608
+ const intent = !!cfg.ragConfigEnabled;
3609
+ const effective = !!cfg.ragEnabled;
3610
+ mode = effective ? 'active' : (intent ? 'pending' : 'off');
3611
+ }
3453
3612
  el.style.display = '';
3454
- if (effective) {
3613
+ if (mode === 'active') {
3455
3614
  el.textContent = 'RAG · on';
3456
3615
  el.className = 'topbar-stat rag-on';
3457
3616
  el.title = 'Mnestra hybrid search + termdeck flashback enabled';
3458
- } else if (intent) {
3617
+ } else if (mode === 'pending') {
3459
3618
  el.textContent = 'RAG · pending';
3460
3619
  el.className = 'topbar-stat rag-pending';
3461
3620
  el.title = 'RAG enabled in config.yaml but Supabase not wired — see Settings';
@@ -274,11 +274,29 @@
274
274
  try {
275
275
  let data;
276
276
  if (state.mode === 'project' && state.project === '__all__') {
277
+ // Sprint 57 T2 (F-T2-4) — /api/graph/all is now paginated by default
278
+ // (200 rows/page). Per ORCH GREEN-LIGHT 2026-05-05 14:21 ET, the
279
+ // dashboard renders the first 200-node page intentionally rather
280
+ // than accumulating across pages. Trade-offs:
281
+ // - Avoids the 1.2 MB / 862 ms single-shot payload (Sprint 55
282
+ // measurement that motivated F-T2-4).
283
+ // - Keeps edge fidelity simple: the server's both-endpoints-in-page
284
+ // edge query still gives a coherent intra-page subgraph; no
285
+ // cross-page-edges concern, no accumulator de-dup.
286
+ // - User narrows by project to see specific clusters (same UX
287
+ // guidance as the pre-Sprint-57 truncation toast).
288
+ // If a future sprint wants "load more" pagination, the client can
289
+ // loop via `data.nextCursor` and the server's edge query will need
290
+ // to widen to source_id OR target_id IN page (touch-page) so cross-
291
+ // page edges are recoverable.
277
292
  data = await api('/api/graph/all');
278
293
  if (data.enabled === false) return showDisabled(data);
279
294
  state.rawNodes = data.nodes || [];
280
295
  state.rawEdges = data.edges || [];
281
- if (data.truncated) {
296
+ // Truncation message: trigger when totalAvailable > what we
297
+ // rendered (i.e., this page is partial), regardless of whether
298
+ // the corpus is also above the historical 2000-node cap.
299
+ if (data.totalAvailable && data.totalAvailable > state.rawNodes.length) {
282
300
  showToast(
283
301
  `Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
284
302
  );
@@ -413,6 +413,11 @@
413
413
  outline: 2px solid var(--tg-accent);
414
414
  outline-offset: -2px;
415
415
  }
416
+ .term-panel.file-drop-active {
417
+ outline: 2px dashed var(--tg-accent);
418
+ outline-offset: -2px;
419
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.05) inset;
420
+ }
416
421
 
417
422
  .status-dot {
418
423
  width: 8px; height: 8px;