@jhizzard/termdeck 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/transcript-migration.sql +34 -0
- package/package.json +2 -1
- package/packages/cli/src/auto-orchestrate.js +28 -22
- package/packages/cli/src/doctor.js +296 -16
- package/packages/cli/src/index.js +117 -10
- package/packages/cli/src/init-mnestra.js +31 -7
- package/packages/cli/src/init-rumen.js +30 -33
- package/packages/cli/src/mcp-config.js +174 -0
- package/packages/cli/src/stack.js +61 -11
- package/packages/client/public/app.js +114 -2
- package/packages/client/public/style.css +121 -0
- package/packages/server/src/config.js +96 -0
- package/packages/server/src/index.js +176 -6
- package/packages/server/src/rag.js +43 -0
- package/packages/server/src/setup/migration-runner.js +12 -5
- package/packages/server/src/setup/mnestra-migrations/008_legacy_rag_tables.sql +122 -0
- package/packages/server/src/setup/preconditions.js +24 -4
|
@@ -2002,6 +2002,127 @@
|
|
|
2002
2002
|
justify-content: center;
|
|
2003
2003
|
}
|
|
2004
2004
|
.setup-modal.open { display: flex; }
|
|
2005
|
+
|
|
2006
|
+
/* Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) */
|
|
2007
|
+
.setup-settings {
|
|
2008
|
+
padding: 0 0 16px;
|
|
2009
|
+
margin-bottom: 14px;
|
|
2010
|
+
border-bottom: 1px solid var(--tg-border);
|
|
2011
|
+
}
|
|
2012
|
+
.settings-section {
|
|
2013
|
+
display: flex;
|
|
2014
|
+
flex-direction: column;
|
|
2015
|
+
gap: 6px;
|
|
2016
|
+
}
|
|
2017
|
+
.settings-heading {
|
|
2018
|
+
margin: 0;
|
|
2019
|
+
font-size: 12px;
|
|
2020
|
+
letter-spacing: 0.06em;
|
|
2021
|
+
text-transform: uppercase;
|
|
2022
|
+
color: var(--tg-text-dim);
|
|
2023
|
+
font-family: var(--tg-sans);
|
|
2024
|
+
}
|
|
2025
|
+
.settings-row {
|
|
2026
|
+
display: grid;
|
|
2027
|
+
grid-template-columns: 120px 1fr;
|
|
2028
|
+
gap: 14px;
|
|
2029
|
+
align-items: start;
|
|
2030
|
+
}
|
|
2031
|
+
.settings-copy {
|
|
2032
|
+
margin: 0;
|
|
2033
|
+
font-size: 12px;
|
|
2034
|
+
line-height: 1.45;
|
|
2035
|
+
color: var(--tg-text);
|
|
2036
|
+
}
|
|
2037
|
+
.settings-copy code {
|
|
2038
|
+
font-family: var(--tg-mono);
|
|
2039
|
+
font-size: 11px;
|
|
2040
|
+
background: var(--tg-bg);
|
|
2041
|
+
padding: 1px 5px;
|
|
2042
|
+
border-radius: 3px;
|
|
2043
|
+
}
|
|
2044
|
+
.settings-warn {
|
|
2045
|
+
margin-top: 4px;
|
|
2046
|
+
padding: 8px 10px;
|
|
2047
|
+
font-size: 12px;
|
|
2048
|
+
line-height: 1.4;
|
|
2049
|
+
color: var(--tg-text);
|
|
2050
|
+
background: rgba(224, 175, 104, 0.10);
|
|
2051
|
+
border: 1px solid var(--tg-amber);
|
|
2052
|
+
border-radius: 4px;
|
|
2053
|
+
}
|
|
2054
|
+
.settings-warn code {
|
|
2055
|
+
font-family: var(--tg-mono);
|
|
2056
|
+
font-size: 11px;
|
|
2057
|
+
background: var(--tg-bg);
|
|
2058
|
+
padding: 1px 5px;
|
|
2059
|
+
border-radius: 3px;
|
|
2060
|
+
}
|
|
2061
|
+
/* iOS-style toggle. The native checkbox is visually hidden but stays in
|
|
2062
|
+
the tab order so keyboard + screen reader navigation still work. */
|
|
2063
|
+
.toggle {
|
|
2064
|
+
display: inline-flex;
|
|
2065
|
+
align-items: center;
|
|
2066
|
+
gap: 8px;
|
|
2067
|
+
cursor: pointer;
|
|
2068
|
+
user-select: none;
|
|
2069
|
+
}
|
|
2070
|
+
.toggle input[type="checkbox"] {
|
|
2071
|
+
position: absolute;
|
|
2072
|
+
opacity: 0;
|
|
2073
|
+
width: 1px;
|
|
2074
|
+
height: 1px;
|
|
2075
|
+
pointer-events: none;
|
|
2076
|
+
}
|
|
2077
|
+
.toggle-track {
|
|
2078
|
+
position: relative;
|
|
2079
|
+
display: inline-block;
|
|
2080
|
+
width: 36px;
|
|
2081
|
+
height: 20px;
|
|
2082
|
+
background: var(--tg-border);
|
|
2083
|
+
border-radius: 999px;
|
|
2084
|
+
transition: background 0.15s ease;
|
|
2085
|
+
}
|
|
2086
|
+
.toggle-thumb {
|
|
2087
|
+
position: absolute;
|
|
2088
|
+
top: 2px;
|
|
2089
|
+
left: 2px;
|
|
2090
|
+
width: 16px;
|
|
2091
|
+
height: 16px;
|
|
2092
|
+
background: var(--tg-text-bright);
|
|
2093
|
+
border-radius: 50%;
|
|
2094
|
+
transition: transform 0.15s ease, background 0.15s ease;
|
|
2095
|
+
}
|
|
2096
|
+
.toggle input[type="checkbox"]:checked + .toggle-track {
|
|
2097
|
+
background: var(--tg-green);
|
|
2098
|
+
}
|
|
2099
|
+
.toggle input[type="checkbox"]:checked + .toggle-track .toggle-thumb {
|
|
2100
|
+
transform: translateX(16px);
|
|
2101
|
+
background: var(--tg-bg);
|
|
2102
|
+
}
|
|
2103
|
+
.toggle input[type="checkbox"]:focus-visible + .toggle-track {
|
|
2104
|
+
outline: 2px solid var(--tg-accent);
|
|
2105
|
+
outline-offset: 2px;
|
|
2106
|
+
}
|
|
2107
|
+
.toggle input[type="checkbox"]:disabled + .toggle-track {
|
|
2108
|
+
opacity: 0.5;
|
|
2109
|
+
}
|
|
2110
|
+
.toggle-label {
|
|
2111
|
+
font-size: 12px;
|
|
2112
|
+
color: var(--tg-text);
|
|
2113
|
+
font-family: var(--tg-mono);
|
|
2114
|
+
}
|
|
2115
|
+
/* Topbar #stat-rag — re-purposed as a live RAG state line */
|
|
2116
|
+
.topbar-stat.rag-on {
|
|
2117
|
+
color: var(--tg-green);
|
|
2118
|
+
font-weight: 500;
|
|
2119
|
+
}
|
|
2120
|
+
.topbar-stat.rag-pending {
|
|
2121
|
+
color: var(--tg-amber);
|
|
2122
|
+
}
|
|
2123
|
+
.topbar-stat.rag-off {
|
|
2124
|
+
color: var(--tg-text-dim);
|
|
2125
|
+
}
|
|
2005
2126
|
.setup-backdrop {
|
|
2006
2127
|
position: absolute;
|
|
2007
2128
|
inset: 0;
|
|
@@ -298,11 +298,107 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
|
|
|
298
298
|
return parsed.projects;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
|
|
302
|
+
// this for the dashboard RAG toggle (PATCH /api/config) but the helper is
|
|
303
|
+
// generic — pass a deep partial of the config tree, every leaf in `patch` that
|
|
304
|
+
// matches the whitelist gets written through. Returns the parsed-from-disk
|
|
305
|
+
// post-write tree (NOT post-substitution; we only persist user-authored values
|
|
306
|
+
// here, never substituted secrets).
|
|
307
|
+
//
|
|
308
|
+
// Whitelist deliberately tight. Only fields a UI can safely flip live belong
|
|
309
|
+
// here. Adding a new field is an explicit one-line edit (vs. a freeform writer
|
|
310
|
+
// that would let a buggy/malicious client change `port`, `shell`, or projects).
|
|
311
|
+
//
|
|
312
|
+
// Comments and formatting in config.yaml are NOT preserved — same trade-off
|
|
313
|
+
// as `addProject`. The yaml package's parseDocument API can preserve comments
|
|
314
|
+
// but we'd need to migrate addProject too for consistency; that's a follow-up.
|
|
315
|
+
const UPDATABLE_PATHS = new Set([
|
|
316
|
+
'rag.enabled'
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
function flattenPatch(obj, prefix = '') {
|
|
320
|
+
const out = [];
|
|
321
|
+
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return out;
|
|
322
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
323
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
324
|
+
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
|
|
325
|
+
out.push(...flattenPatch(v, key));
|
|
326
|
+
} else {
|
|
327
|
+
out.push([key, v]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function setPath(obj, segs, value) {
|
|
334
|
+
let cur = obj;
|
|
335
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
336
|
+
const s = segs[i];
|
|
337
|
+
if (cur[s] == null || typeof cur[s] !== 'object') cur[s] = {};
|
|
338
|
+
cur = cur[s];
|
|
339
|
+
}
|
|
340
|
+
cur[segs[segs.length - 1]] = value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function updateConfig(patch, configPath = CONFIG_PATH) {
|
|
344
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
345
|
+
throw new Error('updateConfig: patch must be a plain object');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const flat = flattenPatch(patch);
|
|
349
|
+
if (flat.length === 0) {
|
|
350
|
+
throw new Error('updateConfig: patch is empty');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [key, val] of flat) {
|
|
354
|
+
if (!UPDATABLE_PATHS.has(key)) {
|
|
355
|
+
throw new Error(`updateConfig: ${key} is not in the updatable whitelist`);
|
|
356
|
+
}
|
|
357
|
+
if (key === 'rag.enabled' && typeof val !== 'boolean') {
|
|
358
|
+
throw new Error('updateConfig: rag.enabled must be a boolean');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const yaml = require('yaml');
|
|
363
|
+
let parsed = {};
|
|
364
|
+
if (fs.existsSync(configPath)) {
|
|
365
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
366
|
+
try {
|
|
367
|
+
parsed = yaml.parse(raw) || {};
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`config.yaml is not valid YAML — refusing to overwrite: ${err.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const [key, val] of flat) {
|
|
374
|
+
setPath(parsed, key.split('.'), val);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (fs.existsSync(configPath)) {
|
|
378
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
379
|
+
const bak = `${configPath}.${ts}.bak`;
|
|
380
|
+
try {
|
|
381
|
+
fs.copyFileSync(configPath, bak);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.warn('[config] Could not write backup before updateConfig:', err.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const out = yaml.stringify(parsed);
|
|
388
|
+
fs.writeFileSync(configPath, out, 'utf-8');
|
|
389
|
+
console.log(`[config] updateConfig wrote ${flat.map(([k]) => k).join(', ')}`);
|
|
390
|
+
|
|
391
|
+
return parsed;
|
|
392
|
+
}
|
|
393
|
+
|
|
301
394
|
module.exports = {
|
|
302
395
|
loadConfig,
|
|
303
396
|
addProject,
|
|
397
|
+
updateConfig,
|
|
304
398
|
// exported for tests / introspection
|
|
305
399
|
_parseDotenv: parseDotenv,
|
|
306
400
|
_substituteEnv: substituteEnv,
|
|
401
|
+
_flattenPatch: flattenPatch,
|
|
402
|
+
_UPDATABLE_PATHS: UPDATABLE_PATHS,
|
|
307
403
|
_paths: { CONFIG_DIR, CONFIG_PATH, SECRETS_PATH }
|
|
308
404
|
};
|
|
@@ -61,7 +61,7 @@ const { TranscriptWriter } = require('./transcripts');
|
|
|
61
61
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
62
62
|
const { getFullHealth } = require('./health');
|
|
63
63
|
const { themes, statusColors } = require('./themes');
|
|
64
|
-
const { loadConfig, addProject } = require('./config');
|
|
64
|
+
const { loadConfig, addProject, updateConfig } = require('./config');
|
|
65
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
66
66
|
|
|
67
67
|
function createServer(config) {
|
|
@@ -915,6 +915,120 @@ function createServer(config) {
|
|
|
915
915
|
res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
|
|
916
916
|
});
|
|
917
917
|
|
|
918
|
+
// POST /api/sessions/:id/poke - PTY-flush recovery endpoint
|
|
919
|
+
// Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
|
|
920
|
+
// Used to recover from the post-stop PTY delivery gap where injected input via /input
|
|
921
|
+
// returns 200 OK but never reaches the running TUI process. Tries multiple flush
|
|
922
|
+
// mechanisms in sequence and reports per-attempt status plus session state before/after.
|
|
923
|
+
// Discovered 2026-04-26 / 2026-04-27 during ClaimGuard Sprints 4-6 (TMR 4+1 orchestration);
|
|
924
|
+
// see ~/.claude/plans/skill-tmr-orchestrate/known-issues/2026-04-27-pty-delivery-gap.md
|
|
925
|
+
app.post('/api/sessions/:id/poke', async (req, res) => {
|
|
926
|
+
const session = sessions.get(req.params.id);
|
|
927
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
928
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
929
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const { methods } = req.body || {};
|
|
933
|
+
const requested = Array.isArray(methods) && methods.length > 0
|
|
934
|
+
? methods
|
|
935
|
+
: ['all'];
|
|
936
|
+
const runAll = requested.includes('all');
|
|
937
|
+
const wants = (m) => runAll || requested.includes(m);
|
|
938
|
+
|
|
939
|
+
const before = {
|
|
940
|
+
status: session.meta.status,
|
|
941
|
+
statusDetail: session.meta.statusDetail || '',
|
|
942
|
+
lastActivity: session.meta.lastActivity,
|
|
943
|
+
pid: session.pty.pid,
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const attempts = [];
|
|
947
|
+
|
|
948
|
+
// Attempt 1: SIGCONT — wakes the child process if it's somehow stopped (job-control state).
|
|
949
|
+
// Harmless when the process is already running.
|
|
950
|
+
if (wants('sigcont')) {
|
|
951
|
+
try {
|
|
952
|
+
process.kill(session.pty.pid, 'SIGCONT');
|
|
953
|
+
attempts.push({ method: 'sigcont', ok: true });
|
|
954
|
+
} catch (err) {
|
|
955
|
+
attempts.push({ method: 'sigcont', ok: false, error: err.message });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Attempt 2: bracketed-paste sequence wrapping a single CR.
|
|
960
|
+
// Some TUIs treat bracketed-paste differently from raw input; this is a documented
|
|
961
|
+
// (and previously untested) workaround mentioned in the TermDeck API reference.
|
|
962
|
+
if (wants('bracketed-paste')) {
|
|
963
|
+
try {
|
|
964
|
+
session.pty.write('\x1b[200~\r\x1b[201~');
|
|
965
|
+
attempts.push({ method: 'bracketed-paste', ok: true });
|
|
966
|
+
} catch (err) {
|
|
967
|
+
attempts.push({ method: 'bracketed-paste', ok: false, error: err.message });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Wait briefly between attempts so each one has a chance to take effect
|
|
972
|
+
// before the next floods the buffer.
|
|
973
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
974
|
+
|
|
975
|
+
// Attempt 3: triple CR — multiple Enter keypresses in case the TUI needs more
|
|
976
|
+
// than one to register. Each \r is a literal Enter (zsh/readline submit).
|
|
977
|
+
if (wants('cr-flood')) {
|
|
978
|
+
try {
|
|
979
|
+
session.pty.write('\r\r\r');
|
|
980
|
+
attempts.push({ method: 'cr-flood', ok: true });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
attempts.push({ method: 'cr-flood', ok: false, error: err.message });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Final settle delay so `after` reflects the result of all attempts.
|
|
987
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
988
|
+
|
|
989
|
+
const after = {
|
|
990
|
+
status: session.meta.status,
|
|
991
|
+
statusDetail: session.meta.statusDetail || '',
|
|
992
|
+
lastActivity: session.meta.lastActivity,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// Heuristic recovery signal: if lastActivity advanced between before and after,
|
|
996
|
+
// at least one attempt got the TUI to consume input. Not definitive (the TUI
|
|
997
|
+
// might have advanced for other reasons) but a useful hint to the caller.
|
|
998
|
+
const advanced = before.lastActivity !== after.lastActivity;
|
|
999
|
+
|
|
1000
|
+
res.json({
|
|
1001
|
+
ok: true,
|
|
1002
|
+
pid: session.pty.pid,
|
|
1003
|
+
before,
|
|
1004
|
+
after,
|
|
1005
|
+
advanced,
|
|
1006
|
+
attempts,
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// GET /api/sessions/:id/buffer - lightweight introspection of recent input writes
|
|
1011
|
+
// Returns the session's recent _inputBuffer state (what the orchestrator has
|
|
1012
|
+
// written via /input that may or may not have been consumed by the TUI yet).
|
|
1013
|
+
// Useful for diagnosing whether bytes are queued vs consumed.
|
|
1014
|
+
app.get('/api/sessions/:id/buffer', (req, res) => {
|
|
1015
|
+
const session = sessions.get(req.params.id);
|
|
1016
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1017
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
1018
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
1019
|
+
}
|
|
1020
|
+
res.json({
|
|
1021
|
+
ok: true,
|
|
1022
|
+
pid: session.pty.pid,
|
|
1023
|
+
inputBufferLength: (session._inputBuffer || '').length,
|
|
1024
|
+
inputBufferPreview: (session._inputBuffer || '').slice(-200),
|
|
1025
|
+
lastActivity: session.meta.lastActivity,
|
|
1026
|
+
status: session.meta.status,
|
|
1027
|
+
statusDetail: session.meta.statusDetail || '',
|
|
1028
|
+
replyCount: session.meta.replyCount || 0,
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
918
1032
|
// POST /api/sessions/:id/resize - resize terminal
|
|
919
1033
|
app.post('/api/sessions/:id/resize', (req, res) => {
|
|
920
1034
|
const session = sessions.get(req.params.id);
|
|
@@ -955,16 +1069,64 @@ function createServer(config) {
|
|
|
955
1069
|
res.json(t);
|
|
956
1070
|
});
|
|
957
1071
|
|
|
958
|
-
// GET
|
|
959
|
-
|
|
960
|
-
|
|
1072
|
+
// Public-shape helper so GET and PATCH return the same envelope.
|
|
1073
|
+
function publicConfigPayload() {
|
|
1074
|
+
return {
|
|
961
1075
|
projects: config.projects || {},
|
|
962
1076
|
defaultTheme: config.defaultTheme,
|
|
1077
|
+
// ragEnabled is the EFFECTIVE state (after credential eligibility).
|
|
1078
|
+
// ragConfigEnabled is the user's intent from config.yaml. The dashboard
|
|
1079
|
+
// toggle reads ragConfigEnabled (intent) but renders a warning when it
|
|
1080
|
+
// diverges from ragEnabled (e.g. enabled in config but Supabase creds
|
|
1081
|
+
// missing → effective state stays off).
|
|
963
1082
|
ragEnabled: rag.enabled,
|
|
1083
|
+
ragConfigEnabled: !!(config.rag && config.rag.enabled),
|
|
1084
|
+
ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
|
|
964
1085
|
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
965
1086
|
statusColors,
|
|
966
1087
|
firstRun
|
|
967
|
-
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// GET /api/config - current config (sanitized)
|
|
1092
|
+
app.get('/api/config', (req, res) => {
|
|
1093
|
+
res.json(publicConfigPayload());
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// PATCH /api/config - update writable config fields. Sprint 36 T3 Deliverable A.
|
|
1097
|
+
// Body: { rag: { enabled: boolean } } — the only currently writable path.
|
|
1098
|
+
// Persists to ~/.termdeck/config.yaml, live-updates the in-memory integration,
|
|
1099
|
+
// and broadcasts a `config_changed` WS event so all open dashboards re-render
|
|
1100
|
+
// their RAG indicator without a refresh.
|
|
1101
|
+
app.patch('/api/config', (req, res) => {
|
|
1102
|
+
const body = req.body;
|
|
1103
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
1104
|
+
return res.status(400).json({ error: 'body must be a JSON object' });
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
updateConfig(body);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return res.status(400).json({ error: err.message });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (body.rag && typeof body.rag.enabled === 'boolean') {
|
|
1113
|
+
rag.setEnabled(body.rag.enabled);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const payload = publicConfigPayload();
|
|
1117
|
+
|
|
1118
|
+
try {
|
|
1119
|
+
const wsPayload = JSON.stringify({ type: 'config_changed', config: payload });
|
|
1120
|
+
wss.clients.forEach((client) => {
|
|
1121
|
+
if (client.readyState === 1) {
|
|
1122
|
+
try { client.send(wsPayload); } catch (err) { console.error('[ws] config_changed send failed:', err); }
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
console.error('[ws] config_changed broadcast failed:', err);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
res.json(payload);
|
|
968
1130
|
});
|
|
969
1131
|
|
|
970
1132
|
// POST /api/projects - add a new project on the fly, persist to config.yaml
|
|
@@ -1352,6 +1514,14 @@ function createServer(config) {
|
|
|
1352
1514
|
|
|
1353
1515
|
ws.on('close', () => {
|
|
1354
1516
|
console.log(`[ws] Client disconnected from session ${sessionId}`);
|
|
1517
|
+
// Intentional: PTYs survive WS close. The session stays in the manager,
|
|
1518
|
+
// the PTY keeps running, and reconnecting (?session=<id>) re-binds.
|
|
1519
|
+
// PTY teardown happens only via DELETE /api/sessions/:id (user-initiated)
|
|
1520
|
+
// or the PTY's own exit event. Hard-refresh is therefore non-destructive.
|
|
1521
|
+
// Sprint 36 T3 Deliverable C audit (2026-04-27): the briefing predicted
|
|
1522
|
+
// this handler would call pty.kill() — it does not. Joshua's original
|
|
1523
|
+
// hard-refresh-loses-PTYs symptom was the reclaimStalePort SIGKILL chain
|
|
1524
|
+
// (orchestrator hotfix #2, 15:25 ET), not a WS-close cascade.
|
|
1355
1525
|
if (session.ws === ws) {
|
|
1356
1526
|
session.ws = null;
|
|
1357
1527
|
}
|
|
@@ -1619,7 +1789,7 @@ if (require.main === module) {
|
|
|
1619
1789
|
console.log(` Terminals: 0 active`);
|
|
1620
1790
|
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
1621
1791
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
1622
|
-
console.log(` RAG: ${config.rag?.
|
|
1792
|
+
console.log(` RAG: ${config.rag?.enabled === true ? 'on (writing to mnestra_*_memory tables)' : 'off (MCP-only mode)'}`);
|
|
1623
1793
|
console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
|
|
1624
1794
|
console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
|
|
1625
1795
|
console.log(`\n WARNING: TermDeck binds to ${host} only.`);
|
|
@@ -66,6 +66,14 @@ class RAGIntegration {
|
|
|
66
66
|
this._circuitBreaker = new Map(); // table -> { count, open, openedAt, halfOpen }
|
|
67
67
|
this._halfOpenDelayMs = 5 * 60 * 1000;
|
|
68
68
|
|
|
69
|
+
// Status-change debounce: rapid `active ↔ thinking` cycling from busy
|
|
70
|
+
// Claude Code workers (4+1 sprint lanes) produces dozens of status_changed
|
|
71
|
+
// events per second. Untreated, this floods stdout and the outbox; on
|
|
72
|
+
// 2026-04-27 it contributed to two server-kill incidents during Sprint 36.
|
|
73
|
+
// Drop intra-second status edges; let error transitions always pass.
|
|
74
|
+
this._statusWriteAt = new Map(); // sessionId -> last write timestamp (ms)
|
|
75
|
+
this._statusDebounceMs = 1000;
|
|
76
|
+
|
|
69
77
|
if (this.enabled) {
|
|
70
78
|
this._startSync();
|
|
71
79
|
}
|
|
@@ -148,6 +156,16 @@ class RAGIntegration {
|
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
onStatusChanged(session, oldStatus, newStatus) {
|
|
159
|
+
// Always pass through error transitions — those carry signal worth ingesting
|
|
160
|
+
// every time. Debounce only the active ↔ thinking churn that floods the log
|
|
161
|
+
// when a worker cycles tool calls rapidly.
|
|
162
|
+
const isError = newStatus === 'errored' || oldStatus === 'errored';
|
|
163
|
+
if (!isError) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const last = this._statusWriteAt.get(session.id) || 0;
|
|
166
|
+
if (now - last < this._statusDebounceMs) return;
|
|
167
|
+
this._statusWriteAt.set(session.id, now);
|
|
168
|
+
}
|
|
151
169
|
this._recordForSession(session, 'status_changed', {
|
|
152
170
|
from: oldStatus,
|
|
153
171
|
to: newStatus,
|
|
@@ -351,7 +369,32 @@ class RAGIntegration {
|
|
|
351
369
|
stop() {
|
|
352
370
|
if (this._syncTimer) {
|
|
353
371
|
clearInterval(this._syncTimer);
|
|
372
|
+
this._syncTimer = null;
|
|
373
|
+
}
|
|
374
|
+
this._statusWriteAt.clear();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
|
|
378
|
+
// Re-evaluates eligibility — flipping `enabled: true` without configured
|
|
379
|
+
// Supabase creds is a no-op so the live integration never claims to be on
|
|
380
|
+
// when it can't actually push. Returns the resolved effective flag.
|
|
381
|
+
setEnabled(value) {
|
|
382
|
+
const desired = !!value;
|
|
383
|
+
if (this.config && this.config.rag) {
|
|
384
|
+
this.config.rag.enabled = desired;
|
|
385
|
+
}
|
|
386
|
+
const effective = !!(desired && this.supabaseUrl && this.supabaseKey);
|
|
387
|
+
if (effective === this.enabled) return effective;
|
|
388
|
+
this.enabled = effective;
|
|
389
|
+
if (effective) {
|
|
390
|
+
if (!this._syncTimer) this._startSync();
|
|
391
|
+
} else {
|
|
392
|
+
if (this._syncTimer) {
|
|
393
|
+
clearInterval(this._syncTimer);
|
|
394
|
+
this._syncTimer = null;
|
|
395
|
+
}
|
|
354
396
|
}
|
|
397
|
+
return effective;
|
|
355
398
|
}
|
|
356
399
|
}
|
|
357
400
|
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// Unified migration runner for the setup wizard and `termdeck init --mnestra`.
|
|
2
2
|
//
|
|
3
|
-
// Applies the full
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Applies the full bootstrap sequence in order:
|
|
4
|
+
// - Every *.sql file bundled under ./mnestra-migrations, sorted alphabetically
|
|
5
|
+
// by filename (currently 001…008 — Mnestra schema + RPCs + the legacy RAG
|
|
6
|
+
// tables that rag.js writes to when rag.enabled is on).
|
|
7
|
+
// - Then config/transcript-migration.sql (the termdeck_transcripts table).
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
+
// Migrations are discovered via migrations.listMnestraMigrations(), so adding
|
|
10
|
+
// a new file under ./mnestra-migrations/ is the only step needed to ship it —
|
|
11
|
+
// no edits here required as long as the filename sorts after the previous one.
|
|
12
|
+
//
|
|
13
|
+
// Every migration file is authored with IF NOT EXISTS / CREATE OR REPLACE
|
|
14
|
+
// (and DROP POLICY IF EXISTS where applicable) so re-running the sequence is
|
|
15
|
+
// a no-op on an already-configured database.
|
|
9
16
|
|
|
10
17
|
const fs = require('fs');
|
|
11
18
|
const path = require('path');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
-- 008_legacy_rag_tables.sql
|
|
2
|
+
-- Mirror of config/supabase-migration.sql (kept in repo root for reference / manual application).
|
|
3
|
+
-- Auto-applied by packages/server/src/setup/migration-runner.js as the 8th Mnestra migration.
|
|
4
|
+
-- Safe to re-run: all CREATE statements use IF NOT EXISTS guards (and DROP IF EXISTS for policies).
|
|
5
|
+
|
|
6
|
+
-- Mnestra RAG Tables
|
|
7
|
+
-- Multi-layer memory: session → project → developer (cross-project)
|
|
8
|
+
|
|
9
|
+
-- pg_trgm enables gin_trgm_ops used by the commands FTS index below.
|
|
10
|
+
-- Must be created before any object that depends on it.
|
|
11
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
12
|
+
|
|
13
|
+
-- Session-level memory (per terminal session)
|
|
14
|
+
CREATE TABLE IF NOT EXISTS mnestra_session_memory (
|
|
15
|
+
id BIGSERIAL PRIMARY KEY,
|
|
16
|
+
session_id TEXT NOT NULL,
|
|
17
|
+
event_type TEXT NOT NULL,
|
|
18
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
19
|
+
project TEXT,
|
|
20
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
21
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
22
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_session ON mnestra_session_memory(session_id);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_developer ON mnestra_session_memory(developer_id);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_ts ON mnestra_session_memory(timestamp DESC);
|
|
28
|
+
|
|
29
|
+
-- Project-level memory (shared across sessions within a project)
|
|
30
|
+
CREATE TABLE IF NOT EXISTS mnestra_project_memory (
|
|
31
|
+
id BIGSERIAL PRIMARY KEY,
|
|
32
|
+
session_id TEXT NOT NULL,
|
|
33
|
+
event_type TEXT NOT NULL,
|
|
34
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
35
|
+
project TEXT NOT NULL,
|
|
36
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
37
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
38
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_project ON mnestra_project_memory(project);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_developer ON mnestra_project_memory(developer_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_ts ON mnestra_project_memory(timestamp DESC);
|
|
44
|
+
|
|
45
|
+
-- Developer-level memory (cross-project patterns and context)
|
|
46
|
+
CREATE TABLE IF NOT EXISTS mnestra_developer_memory (
|
|
47
|
+
id BIGSERIAL PRIMARY KEY,
|
|
48
|
+
session_id TEXT NOT NULL,
|
|
49
|
+
event_type TEXT NOT NULL,
|
|
50
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
51
|
+
project TEXT,
|
|
52
|
+
developer_id TEXT NOT NULL,
|
|
53
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
54
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_developer_memory_developer ON mnestra_developer_memory(developer_id);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_developer_memory_ts ON mnestra_developer_memory(timestamp DESC);
|
|
59
|
+
|
|
60
|
+
-- Command log (full-text searchable command history)
|
|
61
|
+
CREATE TABLE IF NOT EXISTS mnestra_commands (
|
|
62
|
+
id BIGSERIAL PRIMARY KEY,
|
|
63
|
+
session_id TEXT NOT NULL,
|
|
64
|
+
event_type TEXT NOT NULL DEFAULT 'command_executed',
|
|
65
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
66
|
+
project TEXT,
|
|
67
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
68
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
69
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_commands_developer ON mnestra_commands(developer_id);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_commands_project ON mnestra_commands(project);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_commands_ts ON mnestra_commands(timestamp DESC);
|
|
75
|
+
|
|
76
|
+
-- Enable full-text search on command payloads
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_commands_fts ON mnestra_commands
|
|
78
|
+
USING GIN ((payload->>'command') gin_trgm_ops);
|
|
79
|
+
|
|
80
|
+
-- RLS policies (enable row-level security for multi-tenant)
|
|
81
|
+
ALTER TABLE mnestra_session_memory ENABLE ROW LEVEL SECURITY;
|
|
82
|
+
ALTER TABLE mnestra_project_memory ENABLE ROW LEVEL SECURITY;
|
|
83
|
+
ALTER TABLE mnestra_developer_memory ENABLE ROW LEVEL SECURITY;
|
|
84
|
+
ALTER TABLE mnestra_commands ENABLE ROW LEVEL SECURITY;
|
|
85
|
+
|
|
86
|
+
-- Allow insert from anon/authenticated for the sync process.
|
|
87
|
+
-- DROP-then-CREATE pattern keeps the migration re-run safe on Postgres < 15
|
|
88
|
+
-- (which has no CREATE POLICY IF NOT EXISTS).
|
|
89
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_session_memory;
|
|
90
|
+
CREATE POLICY "Allow insert for all" ON mnestra_session_memory FOR INSERT WITH CHECK (true);
|
|
91
|
+
|
|
92
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_project_memory;
|
|
93
|
+
CREATE POLICY "Allow insert for all" ON mnestra_project_memory FOR INSERT WITH CHECK (true);
|
|
94
|
+
|
|
95
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_developer_memory;
|
|
96
|
+
CREATE POLICY "Allow insert for all" ON mnestra_developer_memory FOR INSERT WITH CHECK (true);
|
|
97
|
+
|
|
98
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_commands;
|
|
99
|
+
CREATE POLICY "Allow insert for all" ON mnestra_commands FOR INSERT WITH CHECK (true);
|
|
100
|
+
|
|
101
|
+
-- Read access scoped to developer_id
|
|
102
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_session_memory;
|
|
103
|
+
CREATE POLICY "Read own data" ON mnestra_session_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
104
|
+
|
|
105
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_project_memory;
|
|
106
|
+
CREATE POLICY "Read own data" ON mnestra_project_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
107
|
+
|
|
108
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_developer_memory;
|
|
109
|
+
CREATE POLICY "Read own data" ON mnestra_developer_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
110
|
+
|
|
111
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_commands;
|
|
112
|
+
CREATE POLICY "Read own data" ON mnestra_commands FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
113
|
+
|
|
114
|
+
-- Useful view: recent activity across all layers
|
|
115
|
+
CREATE OR REPLACE VIEW mnestra_recent_activity AS
|
|
116
|
+
SELECT 'session' as layer, * FROM mnestra_session_memory
|
|
117
|
+
UNION ALL
|
|
118
|
+
SELECT 'project' as layer, * FROM mnestra_project_memory
|
|
119
|
+
UNION ALL
|
|
120
|
+
SELECT 'developer' as layer, * FROM mnestra_developer_memory
|
|
121
|
+
ORDER BY timestamp DESC
|
|
122
|
+
LIMIT 100;
|