@jhizzard/termdeck 1.2.0 → 1.4.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/package.json +2 -2
- package/packages/cli/src/index.js +53 -16
- package/packages/cli/src/init-mnestra.js +131 -0
- package/packages/cli/src/init.js +617 -0
- package/packages/cli/src/mcp-supabase-provision.js +685 -0
- package/packages/cli/src/os-detect.js +297 -0
- package/packages/client/public/app.js +555 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +127 -0
- package/packages/server/src/agent-adapters/claude.js +11 -0
- package/packages/server/src/agent-adapters/codex.js +203 -1
- package/packages/server/src/agent-adapters/gemini.js +4 -0
- package/packages/server/src/agent-adapters/grok.js +4 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +364 -12
- package/packages/server/src/session.js +25 -5
- package/packages/server/src/setup/supabase-mcp.js +42 -3
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +277 -0
- package/packages/stack-installer/assets/hooks/memory-session-end.js +14 -2
|
@@ -120,6 +120,45 @@ const { resolveSpawnShell } = require('./spawn-shell');
|
|
|
120
120
|
// Code does not shell-expand MCP env values; same trap applies anywhere the
|
|
121
121
|
// secrets file flows through a non-shell consumer).
|
|
122
122
|
let _termdeckSecretsCache = null;
|
|
123
|
+
|
|
124
|
+
// Sprint 64 T1 (ORCH SCOPE 16:29 ET item 4) — management-grade tokens that
|
|
125
|
+
// MUST NEVER be merged from ~/.termdeck/secrets.env into a spawned child's
|
|
126
|
+
// env. The wizard's --auto path now explicitly avoids persisting the
|
|
127
|
+
// Supabase PAT here (see packages/cli/src/init.js Phase 3 + the AUDIT-RED
|
|
128
|
+
// resolution comment), but a user might still paste one manually via
|
|
129
|
+
// `vi ~/.termdeck/secrets.env` — defense-in-depth at the reader caps that
|
|
130
|
+
// failure mode. Keys hold:
|
|
131
|
+
// • SUPABASE_ACCESS_TOKEN: Supabase PAT — org-wide management privileges
|
|
132
|
+
// (can create/delete projects, set vault secrets, deploy functions
|
|
133
|
+
// against every project in the org). Highest blast-radius credential
|
|
134
|
+
// in the standard TermDeck stack. The Mnestra hook does NOT need it
|
|
135
|
+
// (per-project SUPABASE_SERVICE_ROLE_KEY is what the hook uses), so
|
|
136
|
+
// dropping it from the PTY merge is loss-free for the running stack.
|
|
137
|
+
// • GITHUB_TOKEN / GITHUB_PAT: Personal Access Tokens for GitHub —
|
|
138
|
+
// repo write access at minimum, often org-wide. Brad's R730 likely
|
|
139
|
+
// doesn't carry one but Joshua's daily-driver does (publish wave
|
|
140
|
+
// workflow). Preventive.
|
|
141
|
+
// • OPENAI_ADMIN_KEY: OpenAI Admin key — billing/org-management.
|
|
142
|
+
// Distinct from OPENAI_API_KEY which is the per-project usage key
|
|
143
|
+
// that Mnestra DOES need. Preventive.
|
|
144
|
+
// • NPM_TOKEN: registry publish token. Preventive.
|
|
145
|
+
const SECRETS_EXCLUDED_FROM_PTY = new Set([
|
|
146
|
+
'SUPABASE_ACCESS_TOKEN',
|
|
147
|
+
'GITHUB_TOKEN',
|
|
148
|
+
'GITHUB_PAT',
|
|
149
|
+
'OPENAI_ADMIN_KEY',
|
|
150
|
+
'NPM_TOKEN',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// Sprint 65 T2 (2.1) — explicit operator-role whitelist for the optional
|
|
154
|
+
// `role` field on POST /api/sessions (Brad's 2026-05-13 v2 dashboard spec,
|
|
155
|
+
// Approach A). `null` is the valid "unroled" value; an absent field also
|
|
156
|
+
// defaults to null. The dashboard renders the ORCH pin when
|
|
157
|
+
// `meta.role === 'orchestrator'`; worker/reviewer/auditor are accepted now
|
|
158
|
+
// for forward-compat with the canonical 3+1+1 role taxonomy. Unknown values
|
|
159
|
+
// are rejected with 400 at the route. Exported for the route-fence test.
|
|
160
|
+
const ALLOWED_SESSION_ROLES = ['orchestrator', 'worker', 'reviewer', 'auditor', null];
|
|
161
|
+
|
|
123
162
|
function readTermdeckSecretsForPty() {
|
|
124
163
|
if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
|
|
125
164
|
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
@@ -137,6 +176,10 @@ function readTermdeckSecretsForPty() {
|
|
|
137
176
|
}
|
|
138
177
|
if (v.startsWith('${') && v.endsWith('}')) continue;
|
|
139
178
|
if (v === '') continue;
|
|
179
|
+
// Sprint 64 T1 (ORCH SCOPE 16:29 ET item 4): EXCLUDE management-grade
|
|
180
|
+
// tokens from the PTY/child-process env merge. See
|
|
181
|
+
// SECRETS_EXCLUDED_FROM_PTY constant above for the rationale per key.
|
|
182
|
+
if (SECRETS_EXCLUDED_FROM_PTY.has(m[1])) continue;
|
|
140
183
|
out[m[1]] = v;
|
|
141
184
|
}
|
|
142
185
|
} catch (_err) {
|
|
@@ -183,6 +226,19 @@ function _setSpawnSessionEndHookImplForTesting(fn) {
|
|
|
183
226
|
_spawnSessionEndHookImpl = typeof fn === 'function' ? fn : _defaultSpawnSessionEndHookImpl;
|
|
184
227
|
}
|
|
185
228
|
|
|
229
|
+
// Sprint 64 T3 — periodic-capture spawn (Investigation 2 of
|
|
230
|
+
// docs/CRITICAL-READ-FIRST-2026-05-07.md). Parallel to _spawnSessionEndHookImpl
|
|
231
|
+
// but targets memory-pre-compact.js. Same indirection rationale: tests stub
|
|
232
|
+
// this to capture the payload deterministically without running detached
|
|
233
|
+
// children inside the test runner.
|
|
234
|
+
function _defaultSpawnPeriodicCaptureHookImpl(hookPath, payload, env) {
|
|
235
|
+
return _defaultSpawnSessionEndHookImpl(hookPath, payload, env);
|
|
236
|
+
}
|
|
237
|
+
let _spawnPeriodicCaptureHookImpl = _defaultSpawnPeriodicCaptureHookImpl;
|
|
238
|
+
function _setSpawnPeriodicCaptureHookImplForTesting(fn) {
|
|
239
|
+
_spawnPeriodicCaptureHookImpl = typeof fn === 'function' ? fn : _defaultSpawnPeriodicCaptureHookImpl;
|
|
240
|
+
}
|
|
241
|
+
|
|
186
242
|
// Fires when a panel's PTY exits. Routes through the adapter registry's
|
|
187
243
|
// new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
|
|
188
244
|
// invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
|
|
@@ -240,6 +296,92 @@ async function onPanelClose(session) {
|
|
|
240
296
|
}
|
|
241
297
|
}
|
|
242
298
|
|
|
299
|
+
// Sprint 64 T3.4 — periodic-capture timer for non-Claude panels.
|
|
300
|
+
//
|
|
301
|
+
// PreCompact only fires inside Claude Code. Codex/Gemini/Grok don't have a
|
|
302
|
+
// PreCompact-equivalent — verified 2026-05-11, see docs/RESTART-PROMPT-
|
|
303
|
+
// 2026-05-11.md § Sprint 64 candidates ("Codex CLI specifically lacks a pre-
|
|
304
|
+
// compact hook surface — `codex --help` exposes no hooks subcommand").
|
|
305
|
+
// Long sessions on those agents grow their transcripts indefinitely; without
|
|
306
|
+
// a periodic snapshot to Mnestra, all of that context evaporates if the
|
|
307
|
+
// process crashes BEFORE the SessionEnd hook can fire on /exit.
|
|
308
|
+
//
|
|
309
|
+
// Strategy: every N minutes (default 10 min, override via
|
|
310
|
+
// `TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS`) the timer resolves the panel's
|
|
311
|
+
// on-disk transcript via the same `adapter.resolveTranscriptPath` path
|
|
312
|
+
// `onPanelClose` uses, then spawns memory-pre-compact.js with
|
|
313
|
+
// `mode: 'periodic_checkpoint'`. The hook handles parsing + embed + POST.
|
|
314
|
+
//
|
|
315
|
+
// Throttle (per the T3 brief): skip if the transcript hasn't grown by
|
|
316
|
+
// >= 1 KB since the last fire. Stop firing once `meta.status === 'exited'`
|
|
317
|
+
// (close-out capture covers that path).
|
|
318
|
+
//
|
|
319
|
+
// Skip rules mirror onPanelClose (Claude has its own PreCompact hook,
|
|
320
|
+
// missing adapter / resolveTranscriptPath / hook file → no-op).
|
|
321
|
+
async function onPanelPeriodicCapture(session) {
|
|
322
|
+
try {
|
|
323
|
+
if (!session || !session.meta) return;
|
|
324
|
+
if (session.meta.status === 'exited') return;
|
|
325
|
+
const adapter = AGENT_ADAPTERS[session.meta.type]
|
|
326
|
+
|| Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
|
|
327
|
+
if (!adapter) return;
|
|
328
|
+
if (adapter.sessionType === 'claude-code') return;
|
|
329
|
+
if (typeof adapter.resolveTranscriptPath !== 'function') return;
|
|
330
|
+
|
|
331
|
+
const transcriptPath = await adapter.resolveTranscriptPath(session);
|
|
332
|
+
if (!transcriptPath) return;
|
|
333
|
+
|
|
334
|
+
// Throttle: compare current transcript size against last-fire bookmark.
|
|
335
|
+
// 1 KB minimum delta keeps the bill bounded on quiet panels (a panel
|
|
336
|
+
// sitting idle at the prompt produces ~0 new bytes per interval).
|
|
337
|
+
let stat;
|
|
338
|
+
try { stat = fs.statSync(transcriptPath); }
|
|
339
|
+
catch (_e) { return; }
|
|
340
|
+
if (!session._periodicCapture) session._periodicCapture = { lastSize: 0, lastFireMs: 0 };
|
|
341
|
+
const grew = stat.size - session._periodicCapture.lastSize;
|
|
342
|
+
if (grew < 1024) return;
|
|
343
|
+
|
|
344
|
+
const hookPath = path.join(os.homedir(), '.claude', 'hooks', 'memory-pre-compact.js');
|
|
345
|
+
if (!fs.existsSync(hookPath)) return;
|
|
346
|
+
|
|
347
|
+
const payload = {
|
|
348
|
+
transcript_path: transcriptPath,
|
|
349
|
+
cwd: session.meta.cwd,
|
|
350
|
+
session_id: session.id,
|
|
351
|
+
sessionType: adapter.sessionType,
|
|
352
|
+
source_agent: adapter.name,
|
|
353
|
+
// Mode discriminator the hook reads in resolveFiringContext —
|
|
354
|
+
// distinguishes "TermDeck server periodic capture" from "Claude Code
|
|
355
|
+
// PreCompact harness fire."
|
|
356
|
+
mode: 'periodic_checkpoint',
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
_spawnPeriodicCaptureHookImpl(hookPath, payload, {
|
|
360
|
+
...process.env,
|
|
361
|
+
...readTermdeckSecretsForPty(),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Update bookmark immediately — even if the spawn fails downstream we
|
|
365
|
+
// don't want to retry the same byte range on the next tick. Worst case
|
|
366
|
+
// we lose one tick; the next 1 KB of growth fires again.
|
|
367
|
+
session._periodicCapture.lastSize = stat.size;
|
|
368
|
+
session._periodicCapture.lastFireMs = Date.now();
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error('[onPanelPeriodicCapture] error:', err && err.message ? err.message : err);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Default interval (10 min). Override via env var; setting to 0 disables the
|
|
375
|
+
// timer entirely. Tests pass a much smaller value (e.g. 100ms) via the env
|
|
376
|
+
// var to exercise the timer path without waiting.
|
|
377
|
+
function _resolvePeriodicCaptureIntervalMs() {
|
|
378
|
+
const raw = process.env.TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS;
|
|
379
|
+
if (!raw) return 10 * 60 * 1000;
|
|
380
|
+
const n = parseInt(raw, 10);
|
|
381
|
+
if (Number.isNaN(n) || n < 0) return 10 * 60 * 1000;
|
|
382
|
+
return n;
|
|
383
|
+
}
|
|
384
|
+
|
|
243
385
|
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
244
386
|
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
245
387
|
// them here and pass them into the helper. If a module is missing (e.g.
|
|
@@ -1107,7 +1249,15 @@ function createServer(config) {
|
|
|
1107
1249
|
|
|
1108
1250
|
// GET /api/sessions - list all active sessions
|
|
1109
1251
|
app.get('/api/sessions', (req, res) => {
|
|
1110
|
-
|
|
1252
|
+
// Sprint 65 T2 (2.2) — exited (dead-PTY) sessions are excluded by default
|
|
1253
|
+
// so an orchestrator polling this endpoint doesn't see dead panels as
|
|
1254
|
+
// live (Brad's "18 windows open, 10 were dead codex cli" — BACKLOG § D.5).
|
|
1255
|
+
// `?includeExited=true` returns the legacy full shape for `termdeck
|
|
1256
|
+
// doctor` + debug tooling. The 2s status_broadcast is intentionally NOT
|
|
1257
|
+
// filtered (it calls bare getAll()) so the dashboard's missed-exit
|
|
1258
|
+
// reconciliation still has exited sessions to work from.
|
|
1259
|
+
const includeExited = req.query.includeExited === 'true';
|
|
1260
|
+
res.json(sessions.getAll({ includeExited }));
|
|
1111
1261
|
});
|
|
1112
1262
|
|
|
1113
1263
|
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
@@ -1115,7 +1265,7 @@ function createServer(config) {
|
|
|
1115
1265
|
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
1116
1266
|
// Returns the Session object regardless of PTY success — status will be
|
|
1117
1267
|
// 'errored' if pty.spawn threw.
|
|
1118
|
-
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
1268
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
|
|
1119
1269
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
1120
1270
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
1121
1271
|
|
|
@@ -1126,29 +1276,84 @@ function createServer(config) {
|
|
|
1126
1276
|
command: command || config.shell,
|
|
1127
1277
|
cwd: resolvedCwd,
|
|
1128
1278
|
theme: theme || config.projects?.[project]?.defaultTheme || config.defaultTheme,
|
|
1129
|
-
reason: reason || 'launched via API'
|
|
1279
|
+
reason: reason || 'launched via API',
|
|
1280
|
+
// Sprint 65 T2 (2.1) — explicit operator role. Route validation has
|
|
1281
|
+
// already rejected unknown values; here `undefined`/`null` → null.
|
|
1282
|
+
role: role || null,
|
|
1130
1283
|
});
|
|
1131
1284
|
|
|
1132
1285
|
if (pty) {
|
|
1133
|
-
//
|
|
1286
|
+
// Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
|
|
1134
1287
|
// (1) no command → spawn the default shell interactively
|
|
1135
1288
|
// (2) command is a plain shell name (zsh, bash, fish, ...)
|
|
1136
1289
|
// → spawn THAT shell interactively, no -c wrapper
|
|
1137
1290
|
// (otherwise `zsh -c zsh` exits immediately)
|
|
1138
|
-
// (3) command
|
|
1291
|
+
// (3) command exactly matches a known agent-adapter binary AND that
|
|
1292
|
+
// adapter declares `spawn.shellWrap === false`
|
|
1293
|
+
// → spawn the adapter's binary directly with
|
|
1294
|
+
// its declared defaultArgs + env merge, no
|
|
1295
|
+
// shell wrapper. Closes Sprint 63
|
|
1296
|
+
// EXIT-CAPTURE-VERIFICATION.md § 6 (the
|
|
1297
|
+
// `zsh -c codex` wrap that likely cost the
|
|
1298
|
+
// codex canary panel its interactive-TTY
|
|
1299
|
+
// context during the 2026-05-11 update-picker
|
|
1300
|
+
// event). The exact-binary gate preserves
|
|
1301
|
+
// user-supplied flags like `claude --resume
|
|
1302
|
+
// <uuid>` — those still fall through to (4).
|
|
1303
|
+
// (4) command is a real command string
|
|
1139
1304
|
// → spawn default shell with -c <command>
|
|
1140
1305
|
const cmdTrim = (command || '').trim();
|
|
1141
1306
|
const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
|
|
1142
1307
|
const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
|
|
1143
1308
|
|
|
1309
|
+
// Sprint 64 T2 (carve-out 2.4) — resolve the matching agent adapter from
|
|
1310
|
+
// the command string. Walk the registry in declaration order; the first
|
|
1311
|
+
// adapter whose `matches(command)` returns true claims the spawn. We
|
|
1312
|
+
// honor `adapter.spawn.shellWrap === false` ONLY when the trimmed command
|
|
1313
|
+
// is exactly the adapter's binary name (no extra args). User-supplied
|
|
1314
|
+
// flags like `codex resume <id>` keep the legacy `zsh -c <command>` path
|
|
1315
|
+
// so we don't silently drop their args.
|
|
1316
|
+
let directSpawnAdapter = null;
|
|
1317
|
+
if (cmdTrim && !isPlainShell) {
|
|
1318
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
1319
|
+
if (!adapter || typeof adapter.matches !== 'function') continue;
|
|
1320
|
+
if (!adapter.matches(cmdTrim)) continue;
|
|
1321
|
+
const spawnDecl = adapter.spawn;
|
|
1322
|
+
if (!spawnDecl || spawnDecl.shellWrap !== false) continue;
|
|
1323
|
+
const binary = spawnDecl.binary;
|
|
1324
|
+
if (typeof binary !== 'string' || binary.length === 0) continue;
|
|
1325
|
+
// Exact-binary gate: only switch to direct-spawn for bare-binary
|
|
1326
|
+
// launches. `codex --resume xyz` falls through to the shell-wrap path.
|
|
1327
|
+
if (cmdTrim !== binary) continue;
|
|
1328
|
+
directSpawnAdapter = adapter;
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1144
1333
|
// Sprint 59 T2 — Brad #5: resolveSpawnShell chains config.shell →
|
|
1145
1334
|
// $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
|
|
1146
1335
|
// `apt remove zsh`) still spawns a working interactive shell instead of
|
|
1147
1336
|
// failing silently from execvp(/bin/zsh).
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1337
|
+
let spawnShell;
|
|
1338
|
+
let args;
|
|
1339
|
+
let adapterSpawnEnv = {};
|
|
1340
|
+
if (directSpawnAdapter) {
|
|
1341
|
+
const decl = directSpawnAdapter.spawn;
|
|
1342
|
+
spawnShell = decl.binary;
|
|
1343
|
+
args = Array.isArray(decl.defaultArgs) ? decl.defaultArgs.slice() : [];
|
|
1344
|
+
// Adapter-declared env overlays (e.g. grok's GROK_MODEL). Empty/`undefined`
|
|
1345
|
+
// values are filtered so they don't shadow process.env.
|
|
1346
|
+
if (decl.env && typeof decl.env === 'object') {
|
|
1347
|
+
for (const [k, v] of Object.entries(decl.env)) {
|
|
1348
|
+
if (typeof v === 'string' && v.length > 0) adapterSpawnEnv[k] = v;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
spawnShell = isPlainShell
|
|
1353
|
+
? cmdTrim
|
|
1354
|
+
: resolveSpawnShell('', config.shell, process.env.SHELL);
|
|
1355
|
+
args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
1356
|
+
}
|
|
1152
1357
|
|
|
1153
1358
|
try {
|
|
1154
1359
|
// Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
|
|
@@ -1163,6 +1368,17 @@ function createServer(config) {
|
|
|
1163
1368
|
secretFallback[k] = v;
|
|
1164
1369
|
}
|
|
1165
1370
|
}
|
|
1371
|
+
// Sprint 64 T2 (carve-out 2.3) — codex pre-spawn version probe.
|
|
1372
|
+
// Fire-and-forget; never blocks spawn. WARN-only when
|
|
1373
|
+
// CODEX_PINNED_VERSION drifts from observed (default install: no probe
|
|
1374
|
+
// comparison, no warning). See codex.js `probeCodexVersion` for full
|
|
1375
|
+
// rationale.
|
|
1376
|
+
if (directSpawnAdapter && directSpawnAdapter.name === 'codex'
|
|
1377
|
+
&& typeof directSpawnAdapter.probeCodexVersion === 'function') {
|
|
1378
|
+
try {
|
|
1379
|
+
directSpawnAdapter.probeCodexVersion();
|
|
1380
|
+
} catch (_probeErr) { /* fail-soft */ }
|
|
1381
|
+
}
|
|
1166
1382
|
const term = pty.spawn(spawnShell, args, {
|
|
1167
1383
|
name: 'xterm-256color',
|
|
1168
1384
|
cols: 120,
|
|
@@ -1171,6 +1387,12 @@ function createServer(config) {
|
|
|
1171
1387
|
env: {
|
|
1172
1388
|
...process.env,
|
|
1173
1389
|
...secretFallback,
|
|
1390
|
+
// Sprint 64 T2 (carve-out 2.4) — adapter-declared env overlays
|
|
1391
|
+
// (e.g. grok's `GROK_MODEL`, codex's `OPENAI_API_KEY` pass-through)
|
|
1392
|
+
// land last so they win over process.env defaults on direct-spawn.
|
|
1393
|
+
// For shell-wrap launches `adapterSpawnEnv` is empty; this is a
|
|
1394
|
+
// no-op spread.
|
|
1395
|
+
...adapterSpawnEnv,
|
|
1174
1396
|
TERMDECK_SESSION: session.id,
|
|
1175
1397
|
TERMDECK_PROJECT: project || '',
|
|
1176
1398
|
TERM: 'xterm-256color',
|
|
@@ -1188,6 +1410,61 @@ function createServer(config) {
|
|
|
1188
1410
|
session.pty = term;
|
|
1189
1411
|
session.pid = term.pid;
|
|
1190
1412
|
session.meta.status = 'active';
|
|
1413
|
+
// Sprint 64 T2 (carve-out 2.4 closure) — when direct-spawn matched
|
|
1414
|
+
// a known adapter, promote `session.meta.type` from its `'shell'`
|
|
1415
|
+
// default to the adapter's canonical `sessionType` immediately. Two
|
|
1416
|
+
// downstream paths benefit:
|
|
1417
|
+
// • T3's periodic-capture timer (Sprint 64) looks up
|
|
1418
|
+
// `getAdapterForSessionType(session.meta.type)` at session-create
|
|
1419
|
+
// time — without this promotion, a bare `command:'codex'` launch
|
|
1420
|
+
// stays as `meta.type='shell'` until adapter output triggers
|
|
1421
|
+
// auto-detect, and the periodic timer never registers
|
|
1422
|
+
// (T4-CODEX 2026-05-14 16:25/16:31 AUDIT-CONCERN).
|
|
1423
|
+
// • `getAdapterForSessionType` callers in session.js' output
|
|
1424
|
+
// analyzer (`_updateStatus`) get the right pattern set on the
|
|
1425
|
+
// very first PTY chunk instead of waiting for the auto-detect
|
|
1426
|
+
// branch.
|
|
1427
|
+
// Only promotes when the caller didn't already specify a concrete
|
|
1428
|
+
// type (i.e., `meta.type === 'shell'`) so explicit requests are
|
|
1429
|
+
// never overridden.
|
|
1430
|
+
if (directSpawnAdapter && session.meta.type === 'shell') {
|
|
1431
|
+
session.meta.type = directSpawnAdapter.sessionType;
|
|
1432
|
+
}
|
|
1433
|
+
// Sprint 64 T2 (carve-out 2.1) — strict spawn timestamp consumed by
|
|
1434
|
+
// codex.js `resolveTranscriptPath` to gate rollout-file candidates
|
|
1435
|
+
// against cross-panel contamination. `session.meta.createdAt` is set
|
|
1436
|
+
// earlier in `sessions.create()` and predates `pty.spawn` by O(ms);
|
|
1437
|
+
// `spawnTimestampMs` captures the actual fork-time so we can reject
|
|
1438
|
+
// pre-spawn rollout files even when another panel's mtime briefly
|
|
1439
|
+
// races past createdAt. See `packages/server/src/agent-adapters/codex.js`
|
|
1440
|
+
// header for the bug shape.
|
|
1441
|
+
session.meta.spawnTimestampMs = Date.now();
|
|
1442
|
+
|
|
1443
|
+
// Sprint 64 T3.4 — register the periodic-capture timer for non-Claude
|
|
1444
|
+
// panels. Claude Code uses the PreCompact hook (Investigation 2
|
|
1445
|
+
// primary signal) — the timer below is the orthogonal fallback for
|
|
1446
|
+
// Codex/Gemini/Grok which have no equivalent harness hook. Cleared
|
|
1447
|
+
// in term.onExit below. Disabled when the interval env var is set
|
|
1448
|
+
// to 0.
|
|
1449
|
+
try {
|
|
1450
|
+
const adapter = AGENT_ADAPTERS[session.meta.type]
|
|
1451
|
+
|| Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
|
|
1452
|
+
const isNonClaudeAdapter = adapter
|
|
1453
|
+
&& adapter.sessionType !== 'claude-code'
|
|
1454
|
+
&& typeof adapter.resolveTranscriptPath === 'function';
|
|
1455
|
+
const intervalMs = _resolvePeriodicCaptureIntervalMs();
|
|
1456
|
+
if (isNonClaudeAdapter && intervalMs > 0) {
|
|
1457
|
+
session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
|
|
1458
|
+
session._periodicCapture.timer = setInterval(() => {
|
|
1459
|
+
onPanelPeriodicCapture(session).catch((err) => {
|
|
1460
|
+
console.error('[onPanelPeriodicCapture] async error:', err && err.message ? err.message : err);
|
|
1461
|
+
});
|
|
1462
|
+
}, intervalMs);
|
|
1463
|
+
// Don't keep the event loop alive solely for this timer — the PTY
|
|
1464
|
+
// / WS / HTTP listeners are the real lifetime anchors.
|
|
1465
|
+
if (session._periodicCapture.timer.unref) session._periodicCapture.timer.unref();
|
|
1466
|
+
}
|
|
1467
|
+
} catch (_periodicErr) { /* fail-soft */ }
|
|
1191
1468
|
|
|
1192
1469
|
// PTY output → analyze + broadcast to WebSocket + transcript archive
|
|
1193
1470
|
term.onData((data) => {
|
|
@@ -1211,6 +1488,10 @@ function createServer(config) {
|
|
|
1211
1488
|
term.onExit(({ exitCode, signal }) => {
|
|
1212
1489
|
session.meta.status = 'exited';
|
|
1213
1490
|
session.meta.exitCode = exitCode;
|
|
1491
|
+
// Sprint 65 T2 (2.4) — stamp the exit timestamp so the panel_exited
|
|
1492
|
+
// WS frame (below) and the 410 body on POST .../input can both
|
|
1493
|
+
// report when the panel died.
|
|
1494
|
+
session.meta.exitedAt = new Date().toISOString();
|
|
1214
1495
|
session.meta.statusDetail = `Exited (${exitCode})${signal ? `, signal ${signal}` : ''}`;
|
|
1215
1496
|
|
|
1216
1497
|
if (session.ws && session.ws.readyState === 1) {
|
|
@@ -1221,11 +1502,47 @@ function createServer(config) {
|
|
|
1221
1502
|
}));
|
|
1222
1503
|
}
|
|
1223
1504
|
|
|
1505
|
+
// Sprint 65 T2 (2.4) — broadcast panel_exited to ALL dashboard WS
|
|
1506
|
+
// clients so the grid can auto-remove the dead tile (Brad's
|
|
1507
|
+
// 2026-05-12 item 2b — CLI panels must auto-close on PTY exit).
|
|
1508
|
+
// Distinct from the `exit` frame above, which targets ONLY this
|
|
1509
|
+
// panel's own socket; panel_exited goes to every connected client
|
|
1510
|
+
// because any of them may be rendering this tile in its grid.
|
|
1511
|
+
// Inlined wss.clients broadcast — same idiom as status_broadcast /
|
|
1512
|
+
// config_changed / projects_changed elsewhere in this file.
|
|
1513
|
+
try {
|
|
1514
|
+
const exitPayload = JSON.stringify({
|
|
1515
|
+
type: 'panel_exited',
|
|
1516
|
+
sessionId: session.id,
|
|
1517
|
+
exitCode,
|
|
1518
|
+
signal: signal || null,
|
|
1519
|
+
exitedAt: session.meta.exitedAt,
|
|
1520
|
+
});
|
|
1521
|
+
wss.clients.forEach((client) => {
|
|
1522
|
+
if (client.readyState === 1) {
|
|
1523
|
+
try { client.send(exitPayload); }
|
|
1524
|
+
catch (err) { console.error('[ws] panel_exited send failed:', err); }
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
console.error('[ws] panel_exited broadcast failed:', err);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1224
1531
|
rag.onSessionEnded(session);
|
|
1225
1532
|
|
|
1226
1533
|
// Fire-and-forget session log (T2.5)
|
|
1227
1534
|
writeSessionLog({ session, config, db, getSessionHistory });
|
|
1228
1535
|
|
|
1536
|
+
// Sprint 64 T3.4 — clear the periodic-capture timer first so a
|
|
1537
|
+
// tick mid-teardown doesn't race onPanelClose. The bookmark stays
|
|
1538
|
+
// on `session._periodicCapture.lastSize` for any future inspection
|
|
1539
|
+
// (test fixtures consult it post-exit).
|
|
1540
|
+
if (session._periodicCapture && session._periodicCapture.timer) {
|
|
1541
|
+
try { clearInterval(session._periodicCapture.timer); }
|
|
1542
|
+
catch (_clrErr) { /* fail-soft */ }
|
|
1543
|
+
session._periodicCapture.timer = null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1229
1546
|
// Sprint 50 T1 — fire the bundled SessionEnd hook for non-Claude
|
|
1230
1547
|
// panels so Codex / Gemini / Grok /exits write to Mnestra the way
|
|
1231
1548
|
// Claude Code already does. onPanelClose handles dispatch +
|
|
@@ -1396,8 +1713,17 @@ function createServer(config) {
|
|
|
1396
1713
|
|
|
1397
1714
|
// POST /api/sessions - create a new terminal session
|
|
1398
1715
|
app.post('/api/sessions', (req, res) => {
|
|
1399
|
-
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
1400
|
-
|
|
1716
|
+
const { command, cwd, project, label, type, theme, reason, role } = req.body || {};
|
|
1717
|
+
// Sprint 65 T2 (2.1) — validate the optional explicit operator-role flag
|
|
1718
|
+
// (Approach A). An absent field (`undefined`) is fine — it defaults to
|
|
1719
|
+
// null in spawnTerminalSession. Any present value must be in the
|
|
1720
|
+
// whitelist (case-sensitive exact match; `null` is allowed). Unknown
|
|
1721
|
+
// values are a 400 so a typo'd role surfaces immediately rather than
|
|
1722
|
+
// silently rendering as an unroled panel.
|
|
1723
|
+
if (role !== undefined && !ALLOWED_SESSION_ROLES.includes(role)) {
|
|
1724
|
+
return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
|
|
1725
|
+
}
|
|
1726
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role });
|
|
1401
1727
|
res.status(201).json(session.toJSON());
|
|
1402
1728
|
});
|
|
1403
1729
|
|
|
@@ -1462,8 +1788,25 @@ function createServer(config) {
|
|
|
1462
1788
|
app.post('/api/sessions/:id/input', (req, res) => {
|
|
1463
1789
|
const session = sessions.get(req.params.id);
|
|
1464
1790
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1791
|
+
// Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
|
|
1792
|
+
// pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
|
|
1793
|
+
// (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
|
|
1794
|
+
// "session never existed"; 410 = "the resource was here, has been
|
|
1795
|
+
// intentionally removed" — the semantically correct + debuggable signal.
|
|
1796
|
+
// Mirrors POST /api/sessions/:id/resize (Sprint 63). The body carries
|
|
1797
|
+
// `error` (backward-compat with the client api()/sendReply() path that
|
|
1798
|
+
// treats a missing `.error` as success — T4-CODEX 19:44) AND `code`
|
|
1799
|
+
// (programmatic discriminator) AND `ok:false`.
|
|
1465
1800
|
if (session.meta.status === 'exited' || !session.pty) {
|
|
1466
|
-
|
|
1801
|
+
const msg = `Panel ${req.params.id} has exited`;
|
|
1802
|
+
return res.status(410).json({
|
|
1803
|
+
ok: false,
|
|
1804
|
+
code: 'panel_exited',
|
|
1805
|
+
error: msg,
|
|
1806
|
+
message: msg,
|
|
1807
|
+
exitCode: session.meta.exitCode ?? null,
|
|
1808
|
+
exitedAt: session.meta.exitedAt || null,
|
|
1809
|
+
});
|
|
1467
1810
|
}
|
|
1468
1811
|
|
|
1469
1812
|
const { text, source, fromSessionId } = req.body || {};
|
|
@@ -2702,9 +3045,18 @@ module.exports = {
|
|
|
2702
3045
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2703
3046
|
readTermdeckSecretsForPty,
|
|
2704
3047
|
_resetTermdeckSecretsCache,
|
|
3048
|
+
// Sprint 64 T1 (ORCH SCOPE 16:29 item 4) — management-token exclusion list.
|
|
3049
|
+
// Exported for `packages/cli/tests/spawn-env-exclusion.test.js` fence.
|
|
3050
|
+
SECRETS_EXCLUDED_FROM_PTY,
|
|
3051
|
+
// Sprint 65 T2 (2.1) — operator-role whitelist, exported for the route fence.
|
|
3052
|
+
ALLOWED_SESSION_ROLES,
|
|
2705
3053
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
2706
3054
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
2707
3055
|
// payload shape, fire-and-forget).
|
|
2708
3056
|
onPanelClose,
|
|
2709
3057
|
_setSpawnSessionEndHookImplForTesting,
|
|
3058
|
+
// Sprint 64 T3.4 — periodic-capture surface (Investigation 2 closure).
|
|
3059
|
+
onPanelPeriodicCapture,
|
|
3060
|
+
_setSpawnPeriodicCaptureHookImplForTesting,
|
|
3061
|
+
_resolvePeriodicCaptureIntervalMs,
|
|
2710
3062
|
};
|
|
@@ -162,6 +162,13 @@ class Session {
|
|
|
162
162
|
this.meta = {
|
|
163
163
|
type: options.type || 'shell', // shell, claude-code, gemini, python-server, one-shot
|
|
164
164
|
project: options.project || null,
|
|
165
|
+
// Sprint 65 T2 (2.1) — explicit operator role (Approach A). One of
|
|
166
|
+
// orchestrator / worker / reviewer / auditor / null. Set at spawn time
|
|
167
|
+
// via POST /api/sessions (route-validated against ALLOWED_SESSION_ROLES);
|
|
168
|
+
// flows through status_broadcast unchanged so the dashboard can pin the
|
|
169
|
+
// ORCH panel. Distinct from `type` (the agent CLI) — role is operator
|
|
170
|
+
// intent, type is the running program.
|
|
171
|
+
role: options.role || null,
|
|
165
172
|
label: options.label || '',
|
|
166
173
|
command: options.command || '',
|
|
167
174
|
cwd: options.cwd || os.homedir(),
|
|
@@ -575,8 +582,8 @@ class SessionManager {
|
|
|
575
582
|
// a PATCH from the dropdown sets it (see updateMeta).
|
|
576
583
|
if (this.db) {
|
|
577
584
|
this.db.prepare(`
|
|
578
|
-
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override)
|
|
579
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
585
|
+
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override, role)
|
|
586
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
580
587
|
`).run(
|
|
581
588
|
session.id,
|
|
582
589
|
session.meta.type,
|
|
@@ -587,7 +594,8 @@ class SessionManager {
|
|
|
587
594
|
session.meta.createdAt,
|
|
588
595
|
session.meta.reason,
|
|
589
596
|
session.meta.theme, // resolved snapshot, legacy column
|
|
590
|
-
session.theme_override
|
|
597
|
+
session.theme_override, // NULL by default
|
|
598
|
+
session.meta.role // Sprint 65 T2 — operator role, NULL by default
|
|
591
599
|
);
|
|
592
600
|
}
|
|
593
601
|
|
|
@@ -599,8 +607,20 @@ class SessionManager {
|
|
|
599
607
|
return this.sessions.get(id);
|
|
600
608
|
}
|
|
601
609
|
|
|
602
|
-
|
|
603
|
-
|
|
610
|
+
// Sprint 65 T2 (2.2) — `opts.includeExited` controls whether PTY-exited
|
|
611
|
+
// sessions appear in the listing. Default is legacy (include everything):
|
|
612
|
+
// the 2s status_broadcast (index.js:2675) and the projects-route live-PTY
|
|
613
|
+
// guard both call bare getAll() and must keep seeing the full set. Only
|
|
614
|
+
// GET /api/sessions opts into the filtered view (default on at the route).
|
|
615
|
+
// Brad's "18 windows open, 10 were dead codex cli" report (BACKLOG § D.5)
|
|
616
|
+
// is the orchestrator polling /api/sessions and seeing dead panels as live.
|
|
617
|
+
getAll(opts = {}) {
|
|
618
|
+
const all = Array.from(this.sessions.values());
|
|
619
|
+
const includeExited = opts.includeExited !== false;
|
|
620
|
+
const visible = includeExited
|
|
621
|
+
? all
|
|
622
|
+
: all.filter((s) => s.meta.status !== 'exited');
|
|
623
|
+
return visible.map((s) => s.toJSON());
|
|
604
624
|
}
|
|
605
625
|
|
|
606
626
|
// Fields a client is allowed to modify via PATCH /api/sessions/:id.
|
|
@@ -17,6 +17,34 @@ const DEFAULT_TIMEOUT_MS = 8000;
|
|
|
17
17
|
const PACKAGE_SPEC = '@supabase/mcp-server-supabase';
|
|
18
18
|
const BINARY_NAME = 'mcp-server-supabase';
|
|
19
19
|
|
|
20
|
+
// Sprint 64 T1 — source-side credential redaction per ORCH SCOPE 16:14 ET.
|
|
21
|
+
//
|
|
22
|
+
// JSON-RPC error messages and child stderr tails from the MCP server may
|
|
23
|
+
// echo back the JWT (anon/service_role keys) or the Supabase PAT that the
|
|
24
|
+
// caller passed in. Wrapping every error string with `redactSecrets()`
|
|
25
|
+
// before throwing prevents accidental leakage into stderr / logs at the
|
|
26
|
+
// source — defense-in-depth complementing caller-side
|
|
27
|
+
// `sanitizeErrorForLogs()` in `packages/cli/src/mcp-supabase-provision.js`.
|
|
28
|
+
//
|
|
29
|
+
// Patterns (greedy match — longest token wins):
|
|
30
|
+
// • JWT-shaped (anon / service_role keys) — `eyJ<>.<>.<>` triple-base64
|
|
31
|
+
// with `[A-Za-z0-9_-]{10,}` per segment. Lower bound at 10 chars per
|
|
32
|
+
// segment avoids false-positive matches on three-part JSON identifiers.
|
|
33
|
+
// • PAT-shaped (`sbp_...`) — Supabase Personal Access Tokens start with
|
|
34
|
+
// `sbp_` and carry 40+ URL-safe characters.
|
|
35
|
+
//
|
|
36
|
+
// Output replaces matches with `[REDACTED:JWT]` / `[REDACTED:PAT]` so a
|
|
37
|
+
// downstream caller can see WHAT shape was redacted without seeing the value.
|
|
38
|
+
const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
|
|
39
|
+
const PAT_PATTERN = /sbp_[A-Za-z0-9]{40,}/g;
|
|
40
|
+
|
|
41
|
+
function redactSecrets(message) {
|
|
42
|
+
if (typeof message !== 'string' || message.length === 0) return message;
|
|
43
|
+
return message
|
|
44
|
+
.replace(JWT_PATTERN, '[REDACTED:JWT]')
|
|
45
|
+
.replace(PAT_PATTERN, '[REDACTED:PAT]');
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
// Detect whether @supabase/mcp-server-supabase can be invoked on this host.
|
|
21
49
|
// Resolution order:
|
|
22
50
|
// 1. A globally installed `mcp-server-supabase` binary on PATH.
|
|
@@ -167,8 +195,12 @@ async function callTool(pat, method, params, opts) {
|
|
|
167
195
|
}
|
|
168
196
|
if (msg && msg.id === id) {
|
|
169
197
|
if (msg.error) {
|
|
198
|
+
// Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
|
|
199
|
+
// substrings from the propagated error message before throwing.
|
|
200
|
+
// Source-side defense; complements caller-side
|
|
201
|
+
// `sanitizeErrorForLogs()` in mcp-supabase-provision.js.
|
|
170
202
|
const detail = msg.error.message || JSON.stringify(msg.error);
|
|
171
|
-
settle(reject, new Error(detail));
|
|
203
|
+
settle(reject, new Error(redactSecrets(detail)));
|
|
172
204
|
} else {
|
|
173
205
|
settle(resolve, msg.result);
|
|
174
206
|
}
|
|
@@ -179,7 +211,11 @@ async function callTool(pat, method, params, opts) {
|
|
|
179
211
|
|
|
180
212
|
child.on('exit', (code, signal) => {
|
|
181
213
|
if (settled) return;
|
|
182
|
-
|
|
214
|
+
// Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
|
|
215
|
+
// substrings from the stderr tail before throwing. A misbehaving
|
|
216
|
+
// MCP child could echo back the SUPABASE_ACCESS_TOKEN env or one of
|
|
217
|
+
// the keys we passed in via params; the redact pass scrubs them.
|
|
218
|
+
const tail = redactSecrets(stderrBuf.slice(-512).trim());
|
|
183
219
|
const why = signal ? `signal=${signal}` : `code=${code}`;
|
|
184
220
|
settle(reject, new Error(`mcp exited (${why})${tail ? ': ' + tail : ''}`));
|
|
185
221
|
});
|
|
@@ -192,4 +228,7 @@ async function callTool(pat, method, params, opts) {
|
|
|
192
228
|
});
|
|
193
229
|
}
|
|
194
230
|
|
|
195
|
-
module.exports = { callTool, detectMcp };
|
|
231
|
+
module.exports = { callTool, detectMcp, redactSecrets };
|
|
232
|
+
// Sprint 64 T1 — exposed for fence tests in packages/cli/tests/mcp-supabase-provision.test.js.
|
|
233
|
+
module.exports.JWT_PATTERN = JWT_PATTERN;
|
|
234
|
+
module.exports.PAT_PATTERN = PAT_PATTERN;
|