@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 +4 -0
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +72 -7
- package/packages/cli/src/index.js +115 -2
- package/packages/cli/src/init-mnestra.js +14 -5
- package/packages/cli/src/stack.js +39 -10
- package/packages/client/public/app.js +164 -5
- package/packages/client/public/graph.js +19 -1
- package/packages/client/public/style.css +5 -0
- package/packages/server/src/agent-adapters/claude.js +28 -1
- package/packages/server/src/config.js +4 -2
- package/packages/server/src/flashback-diag.js +79 -0
- package/packages/server/src/graph-routes.js +142 -25
- package/packages/server/src/index.js +111 -7
- package/packages/server/src/rag-mode.js +43 -0
- package/packages/server/src/setup/dotenv-io.js +12 -3
- package/packages/server/src/setup/supabase-url.js +39 -8
- package/packages/server/src/spawn-shell.js +27 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# TermDeck
|
|
2
2
|
|
|
3
|
+
[](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.
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 =
|
|
462
|
+
const child = _spawn(process.execPath, argv, {
|
|
451
463
|
stdio: 'inherit',
|
|
452
464
|
env: process.env,
|
|
453
465
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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(() =>
|
|
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
|
-
|
|
3452
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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;
|