@jhizzard/termdeck 1.0.12 → 1.0.14

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.12",
3
+ "version": "1.0.14",
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 =====
@@ -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;
@@ -69,6 +69,17 @@ const TOOL = /^(?:\$\s|→\s|exec(?:_command\b|\b)|Running\b|Calling\b)/m;
69
69
  // label when it's done reasoning and waiting on the user.
70
70
  const IDLE = /^codex\s*$/m;
71
71
 
72
+ // End-of-turn terminator (Sprint 60 v1.0.14 fix). After Codex finishes a
73
+ // reply the TUI renders a separator with the elapsed time, e.g.
74
+ // "─ Worked for 2m 50s ──────────" using box-drawing dashes (U+2500). This
75
+ // pattern is unambiguous: it only ever appears when the turn closes and the
76
+ // panel parks waiting for next input. Placed FIRST in the statusFor cascade
77
+ // because the same chunk may also contain a final "Working" spinner update
78
+ // that would otherwise stick `status: 'thinking'` indefinitely. Bit Sprint 59
79
+ // twice — orchestrator's `meta.status` reported "Codex is reasoning..." for
80
+ // 22+ minutes after Codex actually parked at end-of-turn.
81
+ const END_OF_TURN = /─\s*Worked for\s+(?:\d+m\s*)?\d+s\s*─/;
82
+
72
83
  // Error patterns — line-anchored to avoid mid-line "error" mentions in tool
73
84
  // output (grep results, test logs, file dumps) flagging false positives.
74
85
  // Same shape as Claude with codex-specific OpenAI-API failure modes added
@@ -82,6 +93,12 @@ const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|F
82
93
  // ──────────────────────────────────────────────────────────────────────────
83
94
 
84
95
  function statusFor(data) {
96
+ // Sprint 60 v1.0.14: end-of-turn terminator wins over THINKING. Without
97
+ // this branch, a chunk that contains both a final "Working Xs" spinner
98
+ // line AND the closing "Worked for X" separator would stick on 'thinking'.
99
+ if (END_OF_TURN.test(data)) {
100
+ return { status: 'idle', statusDetail: '' };
101
+ }
85
102
  if (THINKING.test(data)) {
86
103
  return { status: 'thinking', statusDetail: 'Codex is reasoning...' };
87
104
  }
@@ -261,6 +278,7 @@ const codexAdapter = {
261
278
  patterns: {
262
279
  prompt: PROMPT,
263
280
  thinking: THINKING,
281
+ endOfTurn: END_OF_TURN,
264
282
  editing: EDITING,
265
283
  tool: TOOL,
266
284
  idle: IDLE,
@@ -59,8 +59,10 @@ function loadSecretsEnv() {
59
59
  const parsed = parseDotenv(raw);
60
60
  const keys = [];
61
61
  for (const [k, v] of Object.entries(parsed)) {
62
- // Do not clobber pre-set process env; shell wins.
63
- if (process.env[k] === undefined) {
62
+ // Do not clobber pre-set process env; shell wins. Sprint 59 T4-CODEX residual
63
+ // fix: also fill when parent env is empty string (Brad's actual failure shape
64
+ // includes DATABASE_URL= in the parent service environment, not only missing).
65
+ if (process.env[k] === undefined || process.env[k] === '') {
64
66
  process.env[k] = v;
65
67
  }
66
68
  keys.push(k);
@@ -82,6 +82,7 @@ const orchestrationPreview = require('./orchestration-preview');
82
82
  const { createPtyReaper } = require('./pty-reaper');
83
83
  const { AGENT_ADAPTERS } = require('./agent-adapters');
84
84
  const { deriveRagMode } = require('./rag-mode');
85
+ const { resolveSpawnShell } = require('./spawn-shell');
85
86
 
86
87
  // Sprint 48 T4 deliverable 2: PTY env-var propagation.
87
88
  // Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
@@ -266,12 +267,92 @@ function _termdeckVersion() {
266
267
  catch { return '0.0.0'; }
267
268
  }
268
269
 
270
+ // Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
271
+ // forensic surfaced 25× `[ws] message handler error: Error: ioctl(2) failed,
272
+ // EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
273
+ // that pty-reaper has already closed (or the child has exited), and
274
+ // `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
275
+ // but the noisy console.error trace pollutes diagnostics and obscures real
276
+ // errors. This helper guards against the race and downgrades the known
277
+ // race-class errors (EBADF, ENOTTY, generic "ioctl failed" message shape) to
278
+ // a silent return. Set TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug
279
+ // for diagnostics.
280
+ function safelyResizePty(session, cols, rows) {
281
+ if (!session || !session.pty) return false;
282
+ if (session.meta && session.meta.status === 'exited') return false;
283
+ try {
284
+ session.pty.resize(cols || 120, rows || 30);
285
+ return true;
286
+ } catch (err) {
287
+ const msg = (err && err.message) || '';
288
+ const code = err && err.code;
289
+ // Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
290
+ // requires explicit EBADF or ENOTTY (in code OR message). The earlier
291
+ // shape — any "ioctl(N) failed" message — was too broad: it would have
292
+ // silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
293
+ // might indicate a real bug. Now: only the specific race-class signals
294
+ // get suppressed; everything else rethrows so it surfaces in logs.
295
+ const isRace =
296
+ code === 'EBADF' ||
297
+ code === 'ENOTTY' ||
298
+ /\b(?:EBADF|ENOTTY)\b/.test(msg);
299
+ if (isRace) {
300
+ if (process.env.TERMDECK_DEBUG_PTY_RACES) {
301
+ console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code || msg}`);
302
+ }
303
+ return false;
304
+ }
305
+ throw err;
306
+ }
307
+ }
308
+
269
309
  function createServer(config) {
270
310
  const app = express();
271
311
  const server = http.createServer(app);
272
312
  const wss = new WebSocketServer({ server, path: '/ws' });
273
313
 
274
- app.use(express.json());
314
+ // Sprint 60 v1.0.14 (Item 2) — pre-screen incoming JSON bodies for unescaped
315
+ // control characters in string contexts. Brad's 2026-05-07 r730 crash
316
+ // forensic logged 9x `SyntaxError: Bad control character in string literal
317
+ // in JSON at position 9` per 13h uptime. The post-Sprint-56 error-handler
318
+ // already returns a structured 400, but body-parser's internal
319
+ // `JSON.parse(body)` throws a verbose SyntaxError whose 10-line stack trace
320
+ // dumps to stderr (Express dev-mode default error logger). The verify
321
+ // callback below fails earlier with a tight ControlCharBodyError that our
322
+ // handler logs as a single-line warning instead of a stack trace.
323
+ //
324
+ // Most likely source of these bodies: agent-to-agent inject through
325
+ // /api/sessions/:id/input where the `text` field contains raw PTY escape
326
+ // sequences (e.g. one panel forwarding terminal output to another). The
327
+ // 400 response is the correct user-facing semantic; this just quiets the
328
+ // logs so real errors aren't drowned in noise.
329
+ app.use(express.json({
330
+ verify: (req, res, buf) => {
331
+ // O(N) single-pass scan. Only checks bytes inside double-quoted string
332
+ // regions so structural whitespace doesn't trigger false positives.
333
+ let inString = false;
334
+ let escape = false;
335
+ for (let i = 0; i < buf.length; i++) {
336
+ const b = buf[i];
337
+ if (!inString) {
338
+ if (b === 0x22) inString = true; // "
339
+ continue;
340
+ }
341
+ if (escape) { escape = false; continue; }
342
+ if (b === 0x5c) { escape = true; continue; } // backslash
343
+ if (b === 0x22) { inString = false; continue; } // closing quote
344
+ // JSON forbids unescaped control chars (0x00-0x1F and 0x7F) inside
345
+ // string literals. Reject with a structured error.
346
+ if (b < 0x20 || b === 0x7f) {
347
+ const err = new Error(`Body contains illegal control character 0x${b.toString(16).padStart(2, '0')} at byte ${i}`);
348
+ err.type = 'entity.verify.failed';
349
+ err.statusCode = 400;
350
+ err.code = 'CONTROL_CHAR_IN_STRING';
351
+ throw err;
352
+ }
353
+ }
354
+ },
355
+ }));
275
356
 
276
357
  // Sprint 56 (T2 F-T2-1) — malformed-JSON body returns JSON 400, not
277
358
  // express's default HTML error page. Pre-Sprint-56 every POST/PATCH
@@ -280,9 +361,23 @@ function createServer(config) {
280
361
  // smoke tests). The status code (400) was correct; only the body
281
362
  // shape regressed. Mounted IMMEDIATELY after express.json() so it
282
363
  // catches body-parse errors before any route handler runs.
364
+ //
365
+ // Sprint 60 v1.0.14 — extended to also catch `entity.verify.failed` from
366
+ // the control-char pre-screen above, AND to log via console.warn (single
367
+ // line) instead of letting Express's default error logger dump a 10-line
368
+ // stack trace to stderr.
283
369
  app.use((err, req, res, next) => {
284
- if (err && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
285
- return res.status(400).json({ error: 'Malformed JSON body', detail: err.message });
370
+ if (err && (
371
+ err.type === 'entity.parse.failed' ||
372
+ err.type === 'entity.verify.failed' ||
373
+ err instanceof SyntaxError
374
+ )) {
375
+ console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path})`);
376
+ return res.status(400).json({
377
+ error: 'Malformed JSON body',
378
+ detail: err.message,
379
+ code: err.code,
380
+ });
286
381
  }
287
382
  return next(err);
288
383
  });
@@ -325,6 +420,26 @@ function createServer(config) {
325
420
  if (orphaned.changes > 0) {
326
421
  console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
327
422
  }
423
+ // Sprint 59 T4-CODEX cleanup: reap upload tempdirs whose owning session is
424
+ // exited or unknown (crashed processes, hard kills, pre-this-version dirs).
425
+ try {
426
+ const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads');
427
+ if (fs.existsSync(uploadsRoot)) {
428
+ const liveIds = new Set();
429
+ try {
430
+ for (const row of db.prepare('SELECT id FROM sessions WHERE exited_at IS NULL').all()) {
431
+ liveIds.add(row.id);
432
+ }
433
+ } catch (_e) { /* live-set empty → all dirs are stale */ }
434
+ let reaped = 0;
435
+ for (const dir of fs.readdirSync(uploadsRoot)) {
436
+ if (!liveIds.has(dir)) {
437
+ try { fs.rmSync(path.join(uploadsRoot, dir), { recursive: true, force: true }); reaped++; } catch (_e) {}
438
+ }
439
+ }
440
+ if (reaped > 0) console.log(`[uploads] Reaped ${reaped} stale upload tempdir(s)`);
441
+ }
442
+ } catch (_err) { /* non-blocking */ }
328
443
  console.log('[db] SQLite initialized');
329
444
  } catch (err) {
330
445
  console.warn('[db] SQLite init failed:', err.message);
@@ -955,7 +1070,13 @@ function createServer(config) {
955
1070
  const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
956
1071
  const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
957
1072
 
958
- const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
1073
+ // Sprint 59 T2 Brad #5: resolveSpawnShell chains config.shell
1074
+ // $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
1075
+ // `apt remove zsh`) still spawns a working interactive shell instead of
1076
+ // failing silently from execvp(/bin/zsh).
1077
+ const spawnShell = isPlainShell
1078
+ ? cmdTrim
1079
+ : resolveSpawnShell('', config.shell, process.env.SHELL);
959
1080
  const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
960
1081
 
961
1082
  try {
@@ -1042,6 +1163,14 @@ function createServer(config) {
1042
1163
  onPanelClose(session).catch((err) => {
1043
1164
  console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
1044
1165
  });
1166
+
1167
+ // Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
1168
+ // per-session upload tempdir so dropped files don't outlive the panel
1169
+ // that received them. Fire-and-forget; never blocks teardown.
1170
+ try {
1171
+ const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
1172
+ fs.rmSync(sessUploadDir, { recursive: true, force: true });
1173
+ } catch (_err) { /* non-blocking */ }
1045
1174
  });
1046
1175
 
1047
1176
  // Wire command logging to SQLite + RAG
@@ -1292,6 +1421,47 @@ function createServer(config) {
1292
1421
  res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
1293
1422
  });
1294
1423
 
1424
+ // POST /api/sessions/:id/upload?name=<filename> - File drop / clipboard image paste
1425
+ // Body: raw octet-stream of the file content (max 50MB).
1426
+ // Writes to /tmp/termdeck-uploads/<sessionId>/<sanitizedName>, returns {ok, path, name, size}.
1427
+ // Client typically follows up with POST /api/sessions/:id/input { text: "@<path> " } so
1428
+ // the agent (Claude/Codex/Gemini/Grok) sees the standard @filepath attachment syntax.
1429
+ // Added Sprint 59 (2026-05-07) to close Brad's "how do I drop a zip into Codex" gap.
1430
+ app.post('/api/sessions/:id/upload',
1431
+ express.raw({ type: '*/*', limit: '50mb' }),
1432
+ (req, res) => {
1433
+ const session = sessions.get(req.params.id);
1434
+ if (!session) return res.status(404).json({ error: 'Session not found' });
1435
+ if (session.meta.status === 'exited' || !session.pty) {
1436
+ return res.status(404).json({ error: 'Session is exited' });
1437
+ }
1438
+
1439
+ const rawName = (req.query.name || '').toString();
1440
+ if (!rawName) return res.status(400).json({ error: 'Missing ?name=' });
1441
+ // Sanitize: strip path traversal + control chars; cap at 200 chars.
1442
+ // Replace anything not alphanumeric / dash / underscore / dot / space with _
1443
+ const safeName = rawName
1444
+ .replace(/[\x00-\x1f\x7f/\\]/g, '_')
1445
+ .replace(/^\.+/, '_')
1446
+ .replace(/\.\.+/g, '_')
1447
+ .slice(0, 200) || 'upload.bin';
1448
+
1449
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
1450
+ return res.status(400).json({ error: 'Empty body' });
1451
+ }
1452
+
1453
+ const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
1454
+ try {
1455
+ fs.mkdirSync(uploadsRoot, { recursive: true, mode: 0o700 });
1456
+ const fullPath = path.join(uploadsRoot, safeName);
1457
+ fs.writeFileSync(fullPath, req.body, { mode: 0o600 });
1458
+ res.json({ ok: true, path: fullPath, name: safeName, size: req.body.length });
1459
+ } catch (err) {
1460
+ return res.status(500).json({ error: err.message });
1461
+ }
1462
+ }
1463
+ );
1464
+
1295
1465
  // POST /api/sessions/:id/poke - PTY-flush recovery endpoint
1296
1466
  // Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
1297
1467
  // Used to recover from the post-stop PTY delivery gap where injected input via /input
@@ -1413,7 +1583,10 @@ function createServer(config) {
1413
1583
 
1414
1584
  const { cols, rows } = req.body;
1415
1585
  try {
1416
- session.pty.resize(cols || 120, rows || 30);
1586
+ const resized = safelyResizePty(session, cols, rows);
1587
+ if (!resized) {
1588
+ return res.status(409).json({ error: 'Session is exited or its PTY is no longer alive' });
1589
+ }
1417
1590
  res.json({ ok: true, cols, rows });
1418
1591
  } catch (err) {
1419
1592
  res.status(500).json({ error: err.message });
@@ -2084,9 +2257,10 @@ function createServer(config) {
2084
2257
  break;
2085
2258
 
2086
2259
  case 'resize':
2087
- if (session.pty) {
2088
- session.pty.resize(parsed.cols || 120, parsed.rows || 30);
2089
- }
2260
+ // Sprint 60 v1.0.14 — safelyResizePty guards against the
2261
+ // pty-reaper-closed-the-fd race that surfaced 25x in Brad's
2262
+ // 13h uptime as ioctl EBADF/ENOTTY noise.
2263
+ safelyResizePty(session, parsed.cols, parsed.rows);
2090
2264
  break;
2091
2265
 
2092
2266
  case 'meta':
@@ -2378,7 +2552,16 @@ if (require.main === module) {
2378
2552
  process.on('SIGTERM', () => handleShutdown('SIGTERM'));
2379
2553
 
2380
2554
  server.listen(port, host, () => {
2381
- console.log(`\n TermDeck running at http://${host}:${port}\n`);
2555
+ // Sprint 60 v1.0.14 (Item 5) per-boot banner with ISO timestamp + PID.
2556
+ // Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
2557
+ // through May 7 with only ONE boot banner at the top. Crash → restart
2558
+ // dropped its own banner somewhere we couldn't find, making post-mortem
2559
+ // diagnosis harder. Per-boot timestamps make crash boundaries trivially
2560
+ // greppable and let `journalctl`/`tail` users scan a single log to find
2561
+ // the most recent restart instantly.
2562
+ const bootIso = new Date().toISOString();
2563
+ console.log(`\n ════ TermDeck server boot · ${bootIso} · pid ${process.pid} ════`);
2564
+ console.log(` TermDeck running at http://${host}:${port}\n`);
2382
2565
  console.log(` Terminals: 0 active`);
2383
2566
  console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
2384
2567
  console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
@@ -2394,6 +2577,10 @@ if (require.main === module) {
2394
2577
  module.exports = {
2395
2578
  createServer,
2396
2579
  loadConfig,
2580
+ // Sprint 60 v1.0.14 (Item 3) — exported so tests can import the production
2581
+ // helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
2582
+ // the prior re-implementation pattern in the test could drift silently.
2583
+ safelyResizePty,
2397
2584
  // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2398
2585
  readTermdeckSecretsForPty,
2399
2586
  _resetTermdeckSecretsCache,
@@ -516,10 +516,29 @@ class Session {
516
516
  }
517
517
 
518
518
  toJSON() {
519
+ const meta = { ...this.meta };
520
+ // Sprint 60 v1.0.14 — stale-status guard. If a panel's status is in the
521
+ // sticky set ('thinking', 'editing') but no PTY output has arrived for
522
+ // STALE_STATUS_THRESHOLD_MS, treat it as parked at end-of-turn and report
523
+ // 'idle' instead. Lazy: only evaluated on serialization (zero timer cost).
524
+ // Backstops adapter-specific end-of-turn detection — Codex's "Worked for"
525
+ // terminator catches the precise case; this catches the general one
526
+ // (Claude's stuck-on-thinking, future adapters that forget end-of-turn,
527
+ // any adapter where the terminator chunk is split across reads). Bit
528
+ // Sprint 59 twice — orchestrator's GET /api/sessions reported sticky
529
+ // 'thinking' for 22 minutes after the panel actually parked.
530
+ const STICKY_STATUSES = Session.STICKY_STATUSES;
531
+ if (STICKY_STATUSES.has(meta.status)) {
532
+ const ageMs = Date.now() - new Date(meta.lastActivity).getTime();
533
+ if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
534
+ meta.status = 'idle';
535
+ meta.statusDetail = '';
536
+ }
537
+ }
519
538
  return {
520
539
  id: this.id,
521
540
  pid: this.pid,
522
- meta: { ...this.meta }
541
+ meta
523
542
  };
524
543
  }
525
544
 
@@ -530,6 +549,13 @@ class Session {
530
549
  }
531
550
  }
532
551
 
552
+ // Sprint 60 v1.0.14 — class statics for the stale-status guard. Exposed on
553
+ // the class (not const-locked inside toJSON) so tests can stub them and so
554
+ // the threshold can be tuned in one place if signal/noise needs adjustment.
555
+ Session.STICKY_STATUSES = new Set(['thinking', 'editing']);
556
+ Session.STALE_STATUS_THRESHOLD_MS = 30000;
557
+
558
+
533
559
  class SessionManager {
534
560
  constructor(db) {
535
561
  this.sessions = new Map();
@@ -40,13 +40,22 @@ function readSecretsRaw(filepath = SECRETS_PATH) {
40
40
  }
41
41
 
42
42
  // Escape a value for safe re-serialization. Wraps in double quotes if the
43
- // value contains whitespace, `#`, or `"`. Always safe to wrap we wrap when
44
- // in doubt to avoid ambiguity with the dotenv parser.
43
+ // value contains whitespace, `#`, or a quote char. `=` was previously in the
44
+ // regex but excluded after Sprint 59 Brad #2 — every Postgres URL with query
45
+ // params (e.g. `?sslmode=require`) contains `=`, and dotenv splits a line on
46
+ // the FIRST `=` only, so subsequent `=` chars in the value need no quoting.
47
+ // Quoting URLs broke the "writer must never add surrounding quotes to a
48
+ // DATABASE_URL" contract; the value still round-tripped because every reader
49
+ // strips matching quotes, but a downstream consumer that sourced the file
50
+ // via `set -a; . secrets.env` and didn't strip would see a literal-quoted
51
+ // value re-introduced into process.env. Keeping the regex tight to actual
52
+ // dotenv ambiguities (whitespace, `#` for comments, embedded quote chars)
53
+ // avoids that round-trip-but-not-quite class of bug at the source.
45
54
  function formatValue(value) {
46
55
  if (value == null) return '';
47
56
  const str = String(value);
48
57
  if (str === '') return '';
49
- const needsQuoting = /[\s#"'=]/.test(str);
58
+ const needsQuoting = /[\s#"']/.test(str);
50
59
  if (!needsQuoting) return str;
51
60
  const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
52
61
  return `"${escaped}"`;
@@ -9,6 +9,26 @@
9
9
  // - init-rumen needs the project ref to run `supabase link --project-ref`
10
10
  // and to substitute into the pg_cron schedule SQL.
11
11
 
12
+ // Brad #2 (Sprint 59) — strip ONE pair of matched surrounding single OR
13
+ // double quotes from a string. Idempotent: a value with no quotes returns
14
+ // unchanged, mismatched quotes (`"foo'`, `bar"`) return unchanged. The
15
+ // dotenv parsers in config.js / dotenv-io.js / launcher.js already strip
16
+ // at file-read time, but Brad's reproducer ships the literal-quoted value
17
+ // through process.env (shell `export DATABASE_URL="\"$URL\""`), bypassing
18
+ // the file parsers entirely. Adding the strip here defends the validator
19
+ // boundary so any caller that hands us a quoted env-var value gets the
20
+ // same handling as a quoted secrets.env line.
21
+ function stripSurroundingQuotes(value) {
22
+ if (typeof value !== 'string') return value;
23
+ if (value.length < 2) return value;
24
+ const first = value[0];
25
+ const last = value[value.length - 1];
26
+ if ((first === '"' || first === "'") && first === last) {
27
+ return value.slice(1, -1);
28
+ }
29
+ return value;
30
+ }
31
+
12
32
  // A Supabase project URL looks like:
13
33
  // https://<project-ref>.supabase.co
14
34
  // The ref is 20 characters of lowercase alphanumerics, but we accept anything
@@ -17,7 +37,7 @@ function parseProjectUrl(url) {
17
37
  if (!url || typeof url !== 'string') {
18
38
  return { ok: false, error: 'empty url' };
19
39
  }
20
- const trimmed = url.trim().replace(/\/+$/, '');
40
+ const trimmed = stripSurroundingQuotes(url.trim()).replace(/\/+$/, '');
21
41
  let u;
22
42
  try {
23
43
  u = new URL(trimmed);
@@ -82,9 +102,10 @@ function looksLikeAnthropicKey(key) {
82
102
  // and direct connection URLs (`postgres://postgres:...@db.<ref>.supabase.co:5432/postgres`).
83
103
  function looksLikePostgresUrl(url) {
84
104
  if (!url || typeof url !== 'string') return 'empty';
105
+ const stripped = stripSurroundingQuotes(url.trim());
85
106
  let u;
86
107
  try {
87
- u = new URL(url);
108
+ u = new URL(stripped);
88
109
  } catch (_err) {
89
110
  return 'not a valid URL';
90
111
  }
@@ -137,16 +158,25 @@ function isTransactionPoolerUrl(parsedUrl) {
137
158
  // because validation is the caller's job (looksLikePostgresUrl handles that).
138
159
  function normalizeDatabaseUrl(url) {
139
160
  if (!url || typeof url !== 'string') return { url, modified: false };
161
+ // Brad #2: strip surrounding quotes silently — `modified` stays scoped
162
+ // to "appended pgbouncer params" so the caller's user-facing message
163
+ // ("Detected transaction pooler URL — appending ...") doesn't fire for
164
+ // a no-op quote strip. The strip itself is reflected in the returned
165
+ // `url` so downstream `new URL(normalized.url)` / pg.Pool consumers
166
+ // don't re-throw.
167
+ const stripped = stripSurroundingQuotes(url.trim());
140
168
  let u;
141
169
  try {
142
- u = new URL(url);
170
+ u = new URL(stripped);
143
171
  } catch (_err) {
144
- return { url, modified: false };
172
+ return { url: stripped, modified: false };
145
173
  }
146
- if (!isTransactionPoolerUrl(u)) return { url, modified: false };
174
+ if (!isTransactionPoolerUrl(u)) return { url: stripped, modified: false };
147
175
 
148
- // Already has pgbouncer set? Don't touch.
149
- if (u.searchParams.has('pgbouncer')) return { url, modified: false };
176
+ // Already has pgbouncer set? Don't touch — but still return the stripped URL,
177
+ // not the original (Sprint 59 T4-CODEX residual fix: pre-fix returned `url`,
178
+ // which would re-leak surrounding quotes from a quoted-pgbouncer-URL secrets.env).
179
+ if (u.searchParams.has('pgbouncer')) return { url: stripped, modified: false };
150
180
 
151
181
  u.searchParams.set('pgbouncer', 'true');
152
182
  // Set connection_limit only if not already set — preserve user intent.
@@ -171,5 +201,6 @@ module.exports = {
171
201
  looksLikePostgresUrl,
172
202
  isTransactionPoolerUrl,
173
203
  normalizeDatabaseUrl,
174
- maskSecret
204
+ maskSecret,
205
+ stripSurroundingQuotes
175
206
  };
@@ -0,0 +1,27 @@
1
+ // Sprint 59 T2 — PTY shell fallback chain helper (Brad #5).
2
+ //
3
+ // Pre-Sprint-59 the call site at packages/server/src/index.js:958 was:
4
+ // const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
5
+ // Three failure modes converged on minimal Linux: (a) config.shell empty/unread
6
+ // because the YAML key was wiped or never set, (b) $SHELL ignored entirely,
7
+ // (c) /bin/zsh absent on the host. Result was a silent
8
+ // `execvp(3) failed: No such file or directory` from pty.spawn. The user's
9
+ // login shell was bypassed.
10
+ //
11
+ // /bin/sh is universally present on POSIX; /bin/zsh is not. The chain is:
12
+ // explicit cmdTrim → user's config.shell → $SHELL → /bin/sh universal floor.
13
+ // Caller (index.js) still owns the isPlainShell vs. -c branching; this helper
14
+ // only resolves the FALLBACK chain for the !isPlainShell branch (and for any
15
+ // future caller that wants a single-source-of-truth shell pick).
16
+ //
17
+ // The function intentionally treats "" and undefined identically — both
18
+ // participate in the falsy-OR chain. That matches how config.shell ends up
19
+ // empty when the user has `shell:` (no value) in ~/.termdeck/config.yaml,
20
+ // and how process.env.SHELL is undefined on container-like environments
21
+ // that strip the inherited shell var.
22
+
23
+ function resolveSpawnShell(cmdTrim, configShell, envShell) {
24
+ return cmdTrim || configShell || envShell || '/bin/sh';
25
+ }
26
+
27
+ module.exports = { resolveSpawnShell };