@jhizzard/termdeck 1.1.1 → 1.3.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/cli/src/stack.js +20 -3
- 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 +18 -8
- package/packages/server/src/agent-adapters/grok.js +4 -0
- package/packages/server/src/health.js +354 -110
- package/packages/server/src/index.js +399 -25
- package/packages/server/src/preflight.js +7 -1
- 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
|
@@ -37,7 +37,7 @@ try {
|
|
|
37
37
|
}
|
|
38
38
|
try { pg = require('pg'); } catch { pg = null; }
|
|
39
39
|
|
|
40
|
-
// Module-level singleton Postgres pool for rumen_insights (
|
|
40
|
+
// Module-level singleton Postgres pool for rumen_insights (the daily-driver DB).
|
|
41
41
|
// Lazy-initialized on first rumen endpoint hit so startup stays fast and
|
|
42
42
|
// servers without DATABASE_URL never pay the connection cost.
|
|
43
43
|
//
|
|
@@ -120,6 +120,36 @@ 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
|
+
|
|
123
153
|
function readTermdeckSecretsForPty() {
|
|
124
154
|
if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
|
|
125
155
|
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
@@ -137,6 +167,10 @@ function readTermdeckSecretsForPty() {
|
|
|
137
167
|
}
|
|
138
168
|
if (v.startsWith('${') && v.endsWith('}')) continue;
|
|
139
169
|
if (v === '') continue;
|
|
170
|
+
// Sprint 64 T1 (ORCH SCOPE 16:29 ET item 4): EXCLUDE management-grade
|
|
171
|
+
// tokens from the PTY/child-process env merge. See
|
|
172
|
+
// SECRETS_EXCLUDED_FROM_PTY constant above for the rationale per key.
|
|
173
|
+
if (SECRETS_EXCLUDED_FROM_PTY.has(m[1])) continue;
|
|
140
174
|
out[m[1]] = v;
|
|
141
175
|
}
|
|
142
176
|
} catch (_err) {
|
|
@@ -183,6 +217,19 @@ function _setSpawnSessionEndHookImplForTesting(fn) {
|
|
|
183
217
|
_spawnSessionEndHookImpl = typeof fn === 'function' ? fn : _defaultSpawnSessionEndHookImpl;
|
|
184
218
|
}
|
|
185
219
|
|
|
220
|
+
// Sprint 64 T3 — periodic-capture spawn (Investigation 2 of
|
|
221
|
+
// docs/CRITICAL-READ-FIRST-2026-05-07.md). Parallel to _spawnSessionEndHookImpl
|
|
222
|
+
// but targets memory-pre-compact.js. Same indirection rationale: tests stub
|
|
223
|
+
// this to capture the payload deterministically without running detached
|
|
224
|
+
// children inside the test runner.
|
|
225
|
+
function _defaultSpawnPeriodicCaptureHookImpl(hookPath, payload, env) {
|
|
226
|
+
return _defaultSpawnSessionEndHookImpl(hookPath, payload, env);
|
|
227
|
+
}
|
|
228
|
+
let _spawnPeriodicCaptureHookImpl = _defaultSpawnPeriodicCaptureHookImpl;
|
|
229
|
+
function _setSpawnPeriodicCaptureHookImplForTesting(fn) {
|
|
230
|
+
_spawnPeriodicCaptureHookImpl = typeof fn === 'function' ? fn : _defaultSpawnPeriodicCaptureHookImpl;
|
|
231
|
+
}
|
|
232
|
+
|
|
186
233
|
// Fires when a panel's PTY exits. Routes through the adapter registry's
|
|
187
234
|
// new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
|
|
188
235
|
// invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
|
|
@@ -240,6 +287,92 @@ async function onPanelClose(session) {
|
|
|
240
287
|
}
|
|
241
288
|
}
|
|
242
289
|
|
|
290
|
+
// Sprint 64 T3.4 — periodic-capture timer for non-Claude panels.
|
|
291
|
+
//
|
|
292
|
+
// PreCompact only fires inside Claude Code. Codex/Gemini/Grok don't have a
|
|
293
|
+
// PreCompact-equivalent — verified 2026-05-11, see docs/RESTART-PROMPT-
|
|
294
|
+
// 2026-05-11.md § Sprint 64 candidates ("Codex CLI specifically lacks a pre-
|
|
295
|
+
// compact hook surface — `codex --help` exposes no hooks subcommand").
|
|
296
|
+
// Long sessions on those agents grow their transcripts indefinitely; without
|
|
297
|
+
// a periodic snapshot to Mnestra, all of that context evaporates if the
|
|
298
|
+
// process crashes BEFORE the SessionEnd hook can fire on /exit.
|
|
299
|
+
//
|
|
300
|
+
// Strategy: every N minutes (default 10 min, override via
|
|
301
|
+
// `TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS`) the timer resolves the panel's
|
|
302
|
+
// on-disk transcript via the same `adapter.resolveTranscriptPath` path
|
|
303
|
+
// `onPanelClose` uses, then spawns memory-pre-compact.js with
|
|
304
|
+
// `mode: 'periodic_checkpoint'`. The hook handles parsing + embed + POST.
|
|
305
|
+
//
|
|
306
|
+
// Throttle (per the T3 brief): skip if the transcript hasn't grown by
|
|
307
|
+
// >= 1 KB since the last fire. Stop firing once `meta.status === 'exited'`
|
|
308
|
+
// (close-out capture covers that path).
|
|
309
|
+
//
|
|
310
|
+
// Skip rules mirror onPanelClose (Claude has its own PreCompact hook,
|
|
311
|
+
// missing adapter / resolveTranscriptPath / hook file → no-op).
|
|
312
|
+
async function onPanelPeriodicCapture(session) {
|
|
313
|
+
try {
|
|
314
|
+
if (!session || !session.meta) return;
|
|
315
|
+
if (session.meta.status === 'exited') return;
|
|
316
|
+
const adapter = AGENT_ADAPTERS[session.meta.type]
|
|
317
|
+
|| Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
|
|
318
|
+
if (!adapter) return;
|
|
319
|
+
if (adapter.sessionType === 'claude-code') return;
|
|
320
|
+
if (typeof adapter.resolveTranscriptPath !== 'function') return;
|
|
321
|
+
|
|
322
|
+
const transcriptPath = await adapter.resolveTranscriptPath(session);
|
|
323
|
+
if (!transcriptPath) return;
|
|
324
|
+
|
|
325
|
+
// Throttle: compare current transcript size against last-fire bookmark.
|
|
326
|
+
// 1 KB minimum delta keeps the bill bounded on quiet panels (a panel
|
|
327
|
+
// sitting idle at the prompt produces ~0 new bytes per interval).
|
|
328
|
+
let stat;
|
|
329
|
+
try { stat = fs.statSync(transcriptPath); }
|
|
330
|
+
catch (_e) { return; }
|
|
331
|
+
if (!session._periodicCapture) session._periodicCapture = { lastSize: 0, lastFireMs: 0 };
|
|
332
|
+
const grew = stat.size - session._periodicCapture.lastSize;
|
|
333
|
+
if (grew < 1024) return;
|
|
334
|
+
|
|
335
|
+
const hookPath = path.join(os.homedir(), '.claude', 'hooks', 'memory-pre-compact.js');
|
|
336
|
+
if (!fs.existsSync(hookPath)) return;
|
|
337
|
+
|
|
338
|
+
const payload = {
|
|
339
|
+
transcript_path: transcriptPath,
|
|
340
|
+
cwd: session.meta.cwd,
|
|
341
|
+
session_id: session.id,
|
|
342
|
+
sessionType: adapter.sessionType,
|
|
343
|
+
source_agent: adapter.name,
|
|
344
|
+
// Mode discriminator the hook reads in resolveFiringContext —
|
|
345
|
+
// distinguishes "TermDeck server periodic capture" from "Claude Code
|
|
346
|
+
// PreCompact harness fire."
|
|
347
|
+
mode: 'periodic_checkpoint',
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
_spawnPeriodicCaptureHookImpl(hookPath, payload, {
|
|
351
|
+
...process.env,
|
|
352
|
+
...readTermdeckSecretsForPty(),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Update bookmark immediately — even if the spawn fails downstream we
|
|
356
|
+
// don't want to retry the same byte range on the next tick. Worst case
|
|
357
|
+
// we lose one tick; the next 1 KB of growth fires again.
|
|
358
|
+
session._periodicCapture.lastSize = stat.size;
|
|
359
|
+
session._periodicCapture.lastFireMs = Date.now();
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error('[onPanelPeriodicCapture] error:', err && err.message ? err.message : err);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Default interval (10 min). Override via env var; setting to 0 disables the
|
|
366
|
+
// timer entirely. Tests pass a much smaller value (e.g. 100ms) via the env
|
|
367
|
+
// var to exercise the timer path without waiting.
|
|
368
|
+
function _resolvePeriodicCaptureIntervalMs() {
|
|
369
|
+
const raw = process.env.TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS;
|
|
370
|
+
if (!raw) return 10 * 60 * 1000;
|
|
371
|
+
const n = parseInt(raw, 10);
|
|
372
|
+
if (Number.isNaN(n) || n < 0) return 10 * 60 * 1000;
|
|
373
|
+
return n;
|
|
374
|
+
}
|
|
375
|
+
|
|
243
376
|
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
244
377
|
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
245
378
|
// them here and pass them into the helper. If a module is missing (e.g.
|
|
@@ -292,31 +425,42 @@ function _termdeckVersion() {
|
|
|
292
425
|
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
293
426
|
// but the noisy console.error trace pollutes diagnostics and obscures real
|
|
294
427
|
// errors. This helper guards against the race and downgrades the known
|
|
295
|
-
// race-class errors (EBADF, ENOTTY
|
|
296
|
-
//
|
|
297
|
-
//
|
|
428
|
+
// race-class errors (EBADF, ENOTTY) to a silent return. Set
|
|
429
|
+
// TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
|
|
430
|
+
//
|
|
431
|
+
// Sprint 63 T1 — `isPtyRaceError(err)` extracted so the WS message-handler
|
|
432
|
+
// outer catch can also downgrade race-class errors that escape the helper's
|
|
433
|
+
// own catch (e.g. if `pty.write` ever races the close, future code paths).
|
|
434
|
+
// `session.pty._destroyed` short-circuit added as belt-and-suspenders for the
|
|
435
|
+
// `term.kill()` → before-`term.onExit`-fires window: the DELETE handler now
|
|
436
|
+
// stamps `_destroyed = true` immediately after kill(), so resize attempts in
|
|
437
|
+
// that interval short-circuit without an ioctl call.
|
|
438
|
+
function isPtyRaceError(err) {
|
|
439
|
+
if (!err) return false;
|
|
440
|
+
const msg = (err.message) || '';
|
|
441
|
+
const code = err.code;
|
|
442
|
+
return code === 'EBADF' ||
|
|
443
|
+
code === 'ENOTTY' ||
|
|
444
|
+
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
445
|
+
}
|
|
446
|
+
|
|
298
447
|
function safelyResizePty(session, cols, rows) {
|
|
299
448
|
if (!session || !session.pty) return false;
|
|
449
|
+
if (session.pty._destroyed) return false;
|
|
300
450
|
if (session.meta && session.meta.status === 'exited') return false;
|
|
301
451
|
try {
|
|
302
452
|
session.pty.resize(cols || 120, rows || 30);
|
|
303
453
|
return true;
|
|
304
454
|
} catch (err) {
|
|
305
|
-
const msg = (err && err.message) || '';
|
|
306
|
-
const code = err && err.code;
|
|
307
455
|
// Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
|
|
308
456
|
// requires explicit EBADF or ENOTTY (in code OR message). The earlier
|
|
309
457
|
// shape — any "ioctl(N) failed" message — was too broad: it would have
|
|
310
458
|
// silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
|
|
311
459
|
// might indicate a real bug. Now: only the specific race-class signals
|
|
312
460
|
// get suppressed; everything else rethrows so it surfaces in logs.
|
|
313
|
-
|
|
314
|
-
code === 'EBADF' ||
|
|
315
|
-
code === 'ENOTTY' ||
|
|
316
|
-
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
317
|
-
if (isRace) {
|
|
461
|
+
if (isPtyRaceError(err)) {
|
|
318
462
|
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
319
|
-
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code ||
|
|
463
|
+
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${err.code || err.message}`);
|
|
320
464
|
}
|
|
321
465
|
return false;
|
|
322
466
|
}
|
|
@@ -324,6 +468,35 @@ function safelyResizePty(session, cols, rows) {
|
|
|
324
468
|
}
|
|
325
469
|
}
|
|
326
470
|
|
|
471
|
+
// Sprint 63 T1 (Item 1.3) — body-parser hardening. The pre-existing
|
|
472
|
+
// `entity.verify.failed` / `entity.parse.failed` handler logged the error
|
|
473
|
+
// message but not WHICH bytes triggered the parse failure. Operators on
|
|
474
|
+
// Brad's r730 saw 9× SyntaxError flood over 13h with no fingerprint to
|
|
475
|
+
// identify the offending caller. `hexEscapePrefix` renders a 32-byte
|
|
476
|
+
// prefix of the raw body in a single-line, log-safe form: printable ASCII
|
|
477
|
+
// kept verbatim, non-printables rendered as `\xNN`, backslash escaped as
|
|
478
|
+
// `\\`. PII-conservative because we cap at 32 bytes (truncation marker `…`
|
|
479
|
+
// appended if more). The error middleware injects this into the existing
|
|
480
|
+
// `console.warn` line so the log signature is identifiable without
|
|
481
|
+
// dumping the full body.
|
|
482
|
+
function hexEscapePrefix(buf, maxBytes = 32) {
|
|
483
|
+
if (!buf || buf.length === 0) return '<no-body>';
|
|
484
|
+
const len = Math.min(buf.length, maxBytes);
|
|
485
|
+
let out = '';
|
|
486
|
+
for (let i = 0; i < len; i++) {
|
|
487
|
+
const b = buf[i];
|
|
488
|
+
if (b === 0x5c) {
|
|
489
|
+
out += '\\\\';
|
|
490
|
+
} else if (b >= 0x20 && b < 0x7f) {
|
|
491
|
+
out += String.fromCharCode(b);
|
|
492
|
+
} else {
|
|
493
|
+
out += '\\x' + b.toString(16).padStart(2, '0');
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (buf.length > maxBytes) out += '…';
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
327
500
|
function createServer(config) {
|
|
328
501
|
const app = express();
|
|
329
502
|
const server = http.createServer(app);
|
|
@@ -346,6 +519,13 @@ function createServer(config) {
|
|
|
346
519
|
// logs so real errors aren't drowned in noise.
|
|
347
520
|
app.use(express.json({
|
|
348
521
|
verify: (req, res, buf) => {
|
|
522
|
+
// Sprint 63 T1 (Item 1.3) — capture a stable copy of the raw body so
|
|
523
|
+
// the error middleware below can render a 32-byte hex-escaped prefix.
|
|
524
|
+
// `Buffer.from(buf)` copies because express may pool the underlying
|
|
525
|
+
// accumulator across requests; without the copy the error handler
|
|
526
|
+
// could see bytes from a later request.
|
|
527
|
+
req.rawBody = Buffer.from(buf);
|
|
528
|
+
|
|
349
529
|
// O(N) single-pass scan. Only checks bytes inside double-quoted string
|
|
350
530
|
// regions so structural whitespace doesn't trigger false positives.
|
|
351
531
|
let inString = false;
|
|
@@ -390,7 +570,13 @@ function createServer(config) {
|
|
|
390
570
|
err.type === 'entity.verify.failed' ||
|
|
391
571
|
err instanceof SyntaxError
|
|
392
572
|
)) {
|
|
393
|
-
|
|
573
|
+
// Sprint 63 T1 (Item 1.3) — append a 32-byte hex-escaped prefix of the
|
|
574
|
+
// raw body so the operator can identify which caller is sending bad
|
|
575
|
+
// JSON without exposing the full payload. Falls through to `<no-body>`
|
|
576
|
+
// if the verify callback never ran (parse error before verify, or no
|
|
577
|
+
// body at all).
|
|
578
|
+
const prefix = hexEscapePrefix(req.rawBody);
|
|
579
|
+
console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path}) prefix="${prefix}"`);
|
|
394
580
|
return res.status(400).json({
|
|
395
581
|
error: 'Malformed JSON body',
|
|
396
582
|
detail: err.message,
|
|
@@ -1077,25 +1263,77 @@ function createServer(config) {
|
|
|
1077
1263
|
});
|
|
1078
1264
|
|
|
1079
1265
|
if (pty) {
|
|
1080
|
-
//
|
|
1266
|
+
// Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
|
|
1081
1267
|
// (1) no command → spawn the default shell interactively
|
|
1082
1268
|
// (2) command is a plain shell name (zsh, bash, fish, ...)
|
|
1083
1269
|
// → spawn THAT shell interactively, no -c wrapper
|
|
1084
1270
|
// (otherwise `zsh -c zsh` exits immediately)
|
|
1085
|
-
// (3) command
|
|
1271
|
+
// (3) command exactly matches a known agent-adapter binary AND that
|
|
1272
|
+
// adapter declares `spawn.shellWrap === false`
|
|
1273
|
+
// → spawn the adapter's binary directly with
|
|
1274
|
+
// its declared defaultArgs + env merge, no
|
|
1275
|
+
// shell wrapper. Closes Sprint 63
|
|
1276
|
+
// EXIT-CAPTURE-VERIFICATION.md § 6 (the
|
|
1277
|
+
// `zsh -c codex` wrap that likely cost the
|
|
1278
|
+
// codex canary panel its interactive-TTY
|
|
1279
|
+
// context during the 2026-05-11 update-picker
|
|
1280
|
+
// event). The exact-binary gate preserves
|
|
1281
|
+
// user-supplied flags like `claude --resume
|
|
1282
|
+
// <uuid>` — those still fall through to (4).
|
|
1283
|
+
// (4) command is a real command string
|
|
1086
1284
|
// → spawn default shell with -c <command>
|
|
1087
1285
|
const cmdTrim = (command || '').trim();
|
|
1088
1286
|
const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
|
|
1089
1287
|
const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
|
|
1090
1288
|
|
|
1289
|
+
// Sprint 64 T2 (carve-out 2.4) — resolve the matching agent adapter from
|
|
1290
|
+
// the command string. Walk the registry in declaration order; the first
|
|
1291
|
+
// adapter whose `matches(command)` returns true claims the spawn. We
|
|
1292
|
+
// honor `adapter.spawn.shellWrap === false` ONLY when the trimmed command
|
|
1293
|
+
// is exactly the adapter's binary name (no extra args). User-supplied
|
|
1294
|
+
// flags like `codex resume <id>` keep the legacy `zsh -c <command>` path
|
|
1295
|
+
// so we don't silently drop their args.
|
|
1296
|
+
let directSpawnAdapter = null;
|
|
1297
|
+
if (cmdTrim && !isPlainShell) {
|
|
1298
|
+
for (const adapter of Object.values(AGENT_ADAPTERS)) {
|
|
1299
|
+
if (!adapter || typeof adapter.matches !== 'function') continue;
|
|
1300
|
+
if (!adapter.matches(cmdTrim)) continue;
|
|
1301
|
+
const spawnDecl = adapter.spawn;
|
|
1302
|
+
if (!spawnDecl || spawnDecl.shellWrap !== false) continue;
|
|
1303
|
+
const binary = spawnDecl.binary;
|
|
1304
|
+
if (typeof binary !== 'string' || binary.length === 0) continue;
|
|
1305
|
+
// Exact-binary gate: only switch to direct-spawn for bare-binary
|
|
1306
|
+
// launches. `codex --resume xyz` falls through to the shell-wrap path.
|
|
1307
|
+
if (cmdTrim !== binary) continue;
|
|
1308
|
+
directSpawnAdapter = adapter;
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1091
1313
|
// Sprint 59 T2 — Brad #5: resolveSpawnShell chains config.shell →
|
|
1092
1314
|
// $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
|
|
1093
1315
|
// `apt remove zsh`) still spawns a working interactive shell instead of
|
|
1094
1316
|
// failing silently from execvp(/bin/zsh).
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1317
|
+
let spawnShell;
|
|
1318
|
+
let args;
|
|
1319
|
+
let adapterSpawnEnv = {};
|
|
1320
|
+
if (directSpawnAdapter) {
|
|
1321
|
+
const decl = directSpawnAdapter.spawn;
|
|
1322
|
+
spawnShell = decl.binary;
|
|
1323
|
+
args = Array.isArray(decl.defaultArgs) ? decl.defaultArgs.slice() : [];
|
|
1324
|
+
// Adapter-declared env overlays (e.g. grok's GROK_MODEL). Empty/`undefined`
|
|
1325
|
+
// values are filtered so they don't shadow process.env.
|
|
1326
|
+
if (decl.env && typeof decl.env === 'object') {
|
|
1327
|
+
for (const [k, v] of Object.entries(decl.env)) {
|
|
1328
|
+
if (typeof v === 'string' && v.length > 0) adapterSpawnEnv[k] = v;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
} else {
|
|
1332
|
+
spawnShell = isPlainShell
|
|
1333
|
+
? cmdTrim
|
|
1334
|
+
: resolveSpawnShell('', config.shell, process.env.SHELL);
|
|
1335
|
+
args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
1336
|
+
}
|
|
1099
1337
|
|
|
1100
1338
|
try {
|
|
1101
1339
|
// Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
|
|
@@ -1110,6 +1348,17 @@ function createServer(config) {
|
|
|
1110
1348
|
secretFallback[k] = v;
|
|
1111
1349
|
}
|
|
1112
1350
|
}
|
|
1351
|
+
// Sprint 64 T2 (carve-out 2.3) — codex pre-spawn version probe.
|
|
1352
|
+
// Fire-and-forget; never blocks spawn. WARN-only when
|
|
1353
|
+
// CODEX_PINNED_VERSION drifts from observed (default install: no probe
|
|
1354
|
+
// comparison, no warning). See codex.js `probeCodexVersion` for full
|
|
1355
|
+
// rationale.
|
|
1356
|
+
if (directSpawnAdapter && directSpawnAdapter.name === 'codex'
|
|
1357
|
+
&& typeof directSpawnAdapter.probeCodexVersion === 'function') {
|
|
1358
|
+
try {
|
|
1359
|
+
directSpawnAdapter.probeCodexVersion();
|
|
1360
|
+
} catch (_probeErr) { /* fail-soft */ }
|
|
1361
|
+
}
|
|
1113
1362
|
const term = pty.spawn(spawnShell, args, {
|
|
1114
1363
|
name: 'xterm-256color',
|
|
1115
1364
|
cols: 120,
|
|
@@ -1118,6 +1367,12 @@ function createServer(config) {
|
|
|
1118
1367
|
env: {
|
|
1119
1368
|
...process.env,
|
|
1120
1369
|
...secretFallback,
|
|
1370
|
+
// Sprint 64 T2 (carve-out 2.4) — adapter-declared env overlays
|
|
1371
|
+
// (e.g. grok's `GROK_MODEL`, codex's `OPENAI_API_KEY` pass-through)
|
|
1372
|
+
// land last so they win over process.env defaults on direct-spawn.
|
|
1373
|
+
// For shell-wrap launches `adapterSpawnEnv` is empty; this is a
|
|
1374
|
+
// no-op spread.
|
|
1375
|
+
...adapterSpawnEnv,
|
|
1121
1376
|
TERMDECK_SESSION: session.id,
|
|
1122
1377
|
TERMDECK_PROJECT: project || '',
|
|
1123
1378
|
TERM: 'xterm-256color',
|
|
@@ -1135,6 +1390,61 @@ function createServer(config) {
|
|
|
1135
1390
|
session.pty = term;
|
|
1136
1391
|
session.pid = term.pid;
|
|
1137
1392
|
session.meta.status = 'active';
|
|
1393
|
+
// Sprint 64 T2 (carve-out 2.4 closure) — when direct-spawn matched
|
|
1394
|
+
// a known adapter, promote `session.meta.type` from its `'shell'`
|
|
1395
|
+
// default to the adapter's canonical `sessionType` immediately. Two
|
|
1396
|
+
// downstream paths benefit:
|
|
1397
|
+
// • T3's periodic-capture timer (Sprint 64) looks up
|
|
1398
|
+
// `getAdapterForSessionType(session.meta.type)` at session-create
|
|
1399
|
+
// time — without this promotion, a bare `command:'codex'` launch
|
|
1400
|
+
// stays as `meta.type='shell'` until adapter output triggers
|
|
1401
|
+
// auto-detect, and the periodic timer never registers
|
|
1402
|
+
// (T4-CODEX 2026-05-14 16:25/16:31 AUDIT-CONCERN).
|
|
1403
|
+
// • `getAdapterForSessionType` callers in session.js' output
|
|
1404
|
+
// analyzer (`_updateStatus`) get the right pattern set on the
|
|
1405
|
+
// very first PTY chunk instead of waiting for the auto-detect
|
|
1406
|
+
// branch.
|
|
1407
|
+
// Only promotes when the caller didn't already specify a concrete
|
|
1408
|
+
// type (i.e., `meta.type === 'shell'`) so explicit requests are
|
|
1409
|
+
// never overridden.
|
|
1410
|
+
if (directSpawnAdapter && session.meta.type === 'shell') {
|
|
1411
|
+
session.meta.type = directSpawnAdapter.sessionType;
|
|
1412
|
+
}
|
|
1413
|
+
// Sprint 64 T2 (carve-out 2.1) — strict spawn timestamp consumed by
|
|
1414
|
+
// codex.js `resolveTranscriptPath` to gate rollout-file candidates
|
|
1415
|
+
// against cross-panel contamination. `session.meta.createdAt` is set
|
|
1416
|
+
// earlier in `sessions.create()` and predates `pty.spawn` by O(ms);
|
|
1417
|
+
// `spawnTimestampMs` captures the actual fork-time so we can reject
|
|
1418
|
+
// pre-spawn rollout files even when another panel's mtime briefly
|
|
1419
|
+
// races past createdAt. See `packages/server/src/agent-adapters/codex.js`
|
|
1420
|
+
// header for the bug shape.
|
|
1421
|
+
session.meta.spawnTimestampMs = Date.now();
|
|
1422
|
+
|
|
1423
|
+
// Sprint 64 T3.4 — register the periodic-capture timer for non-Claude
|
|
1424
|
+
// panels. Claude Code uses the PreCompact hook (Investigation 2
|
|
1425
|
+
// primary signal) — the timer below is the orthogonal fallback for
|
|
1426
|
+
// Codex/Gemini/Grok which have no equivalent harness hook. Cleared
|
|
1427
|
+
// in term.onExit below. Disabled when the interval env var is set
|
|
1428
|
+
// to 0.
|
|
1429
|
+
try {
|
|
1430
|
+
const adapter = AGENT_ADAPTERS[session.meta.type]
|
|
1431
|
+
|| Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
|
|
1432
|
+
const isNonClaudeAdapter = adapter
|
|
1433
|
+
&& adapter.sessionType !== 'claude-code'
|
|
1434
|
+
&& typeof adapter.resolveTranscriptPath === 'function';
|
|
1435
|
+
const intervalMs = _resolvePeriodicCaptureIntervalMs();
|
|
1436
|
+
if (isNonClaudeAdapter && intervalMs > 0) {
|
|
1437
|
+
session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
|
|
1438
|
+
session._periodicCapture.timer = setInterval(() => {
|
|
1439
|
+
onPanelPeriodicCapture(session).catch((err) => {
|
|
1440
|
+
console.error('[onPanelPeriodicCapture] async error:', err && err.message ? err.message : err);
|
|
1441
|
+
});
|
|
1442
|
+
}, intervalMs);
|
|
1443
|
+
// Don't keep the event loop alive solely for this timer — the PTY
|
|
1444
|
+
// / WS / HTTP listeners are the real lifetime anchors.
|
|
1445
|
+
if (session._periodicCapture.timer.unref) session._periodicCapture.timer.unref();
|
|
1446
|
+
}
|
|
1447
|
+
} catch (_periodicErr) { /* fail-soft */ }
|
|
1138
1448
|
|
|
1139
1449
|
// PTY output → analyze + broadcast to WebSocket + transcript archive
|
|
1140
1450
|
term.onData((data) => {
|
|
@@ -1173,6 +1483,16 @@ function createServer(config) {
|
|
|
1173
1483
|
// Fire-and-forget session log (T2.5)
|
|
1174
1484
|
writeSessionLog({ session, config, db, getSessionHistory });
|
|
1175
1485
|
|
|
1486
|
+
// Sprint 64 T3.4 — clear the periodic-capture timer first so a
|
|
1487
|
+
// tick mid-teardown doesn't race onPanelClose. The bookmark stays
|
|
1488
|
+
// on `session._periodicCapture.lastSize` for any future inspection
|
|
1489
|
+
// (test fixtures consult it post-exit).
|
|
1490
|
+
if (session._periodicCapture && session._periodicCapture.timer) {
|
|
1491
|
+
try { clearInterval(session._periodicCapture.timer); }
|
|
1492
|
+
catch (_clrErr) { /* fail-soft */ }
|
|
1493
|
+
session._periodicCapture.timer = null;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1176
1496
|
// Sprint 50 T1 — fire the bundled SessionEnd hook for non-Claude
|
|
1177
1497
|
// panels so Codex / Gemini / Grok /exits write to Mnestra the way
|
|
1178
1498
|
// Claude Code already does. onPanelClose handles dispatch +
|
|
@@ -1189,6 +1509,18 @@ function createServer(config) {
|
|
|
1189
1509
|
const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1190
1510
|
fs.rmSync(sessUploadDir, { recursive: true, force: true });
|
|
1191
1511
|
} catch (_err) { /* non-blocking */ }
|
|
1512
|
+
|
|
1513
|
+
// Sprint 63 T1 (Item 1.1) — null `session.pty` so the wrapper is
|
|
1514
|
+
// eligible for GC and downstream `if (session.pty)` guards correctly
|
|
1515
|
+
// identify the exited state. Root cause of Joshua's 2026-05-08/09
|
|
1516
|
+
// overnight `kern.tty.ptmx_max=511` exhaustion (516 fds for 4 panels):
|
|
1517
|
+
// without this nulling, node-pty's wrapper stayed pinned by onData /
|
|
1518
|
+
// onExit closures even after the child exited, holding the master
|
|
1519
|
+
// fd until next GC pass. Set AFTER `onPanelClose` fires (fire-and-
|
|
1520
|
+
// forget; reads `session.meta` + `session.id`, not `session.pty`) and
|
|
1521
|
+
// AFTER the upload-dir cleanup so any sync reader above this line
|
|
1522
|
+
// sees the original wrapper.
|
|
1523
|
+
session.pty = null;
|
|
1192
1524
|
});
|
|
1193
1525
|
|
|
1194
1526
|
// Wire command logging to SQLite + RAG
|
|
@@ -1346,7 +1678,7 @@ function createServer(config) {
|
|
|
1346
1678
|
});
|
|
1347
1679
|
|
|
1348
1680
|
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
1349
|
-
// Reuses the
|
|
1681
|
+
// Reuses the daily-driver pg pool (same DATABASE_URL serves memory_items +
|
|
1350
1682
|
// memory_relationships alongside rumen_*). Graceful-degrades when the pool
|
|
1351
1683
|
// is absent.
|
|
1352
1684
|
createGraphRoutes({
|
|
@@ -1376,6 +1708,14 @@ function createServer(config) {
|
|
|
1376
1708
|
// Kill PTY process
|
|
1377
1709
|
if (session.pty) {
|
|
1378
1710
|
try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
|
|
1711
|
+
// Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
|
|
1712
|
+
// so `safelyResizePty` can short-circuit any resize attempts that arrive
|
|
1713
|
+
// in the kill()→onExit window. node-pty's `kill()` only signals the
|
|
1714
|
+
// child; onExit fires asynchronously once the child reaps. Without this
|
|
1715
|
+
// marker, a WS resize message in that window would ioctl a fd whose
|
|
1716
|
+
// child has just SIGHUP'd, surfacing as EBADF/ENOTTY. node-pty doesn't
|
|
1717
|
+
// set this property itself; the convention is owned by TermDeck.
|
|
1718
|
+
session.pty._destroyed = true;
|
|
1379
1719
|
}
|
|
1380
1720
|
|
|
1381
1721
|
sessions.remove(req.params.id);
|
|
@@ -1595,15 +1935,23 @@ function createServer(config) {
|
|
|
1595
1935
|
});
|
|
1596
1936
|
|
|
1597
1937
|
// POST /api/sessions/:id/resize - resize terminal
|
|
1938
|
+
// Sprint 63 T1 (Item 1.2) — distinguish "session never existed" (404) from
|
|
1939
|
+
// "session exists but PTY has exited" (410 Gone). Pre-Sprint-63 both paths
|
|
1940
|
+
// collapsed to 404 (when session.pty was null after the PTY-leak fix) or
|
|
1941
|
+
// 409 (when safelyResizePty returned false). 410 is the semantically
|
|
1942
|
+
// correct response: the resource was here, the resource is now gone.
|
|
1598
1943
|
app.post('/api/sessions/:id/resize', (req, res) => {
|
|
1599
1944
|
const session = sessions.get(req.params.id);
|
|
1600
|
-
if (!session
|
|
1945
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1946
|
+
if (!session.pty || (session.meta && session.meta.status === 'exited')) {
|
|
1947
|
+
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
1948
|
+
}
|
|
1601
1949
|
|
|
1602
1950
|
const { cols, rows } = req.body;
|
|
1603
1951
|
try {
|
|
1604
1952
|
const resized = safelyResizePty(session, cols, rows);
|
|
1605
1953
|
if (!resized) {
|
|
1606
|
-
return res.status(
|
|
1954
|
+
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
1607
1955
|
}
|
|
1608
1956
|
res.json({ ok: true, cols, rows });
|
|
1609
1957
|
} catch (err) {
|
|
@@ -2027,7 +2375,7 @@ function createServer(config) {
|
|
|
2027
2375
|
});
|
|
2028
2376
|
|
|
2029
2377
|
// ==================== Rumen insights (Sprint 4 T2) ====================
|
|
2030
|
-
// Read-only access to rumen_insights + rumen_jobs in the
|
|
2378
|
+
// Read-only access to rumen_insights + rumen_jobs in the daily-driver Postgres
|
|
2031
2379
|
// instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
|
|
2032
2380
|
|
|
2033
2381
|
function rumenUnreachable(res) {
|
|
@@ -2268,7 +2616,7 @@ function createServer(config) {
|
|
|
2268
2616
|
|
|
2269
2617
|
switch (parsed.type) {
|
|
2270
2618
|
case 'input':
|
|
2271
|
-
if (session.pty) {
|
|
2619
|
+
if (session.pty && !session.pty._destroyed) {
|
|
2272
2620
|
session.pty.write(parsed.data);
|
|
2273
2621
|
session.trackInput(parsed.data);
|
|
2274
2622
|
}
|
|
@@ -2289,7 +2637,21 @@ function createServer(config) {
|
|
|
2289
2637
|
}));
|
|
2290
2638
|
break;
|
|
2291
2639
|
}
|
|
2292
|
-
} catch (err) {
|
|
2640
|
+
} catch (err) {
|
|
2641
|
+
// Sprint 63 T1 (Item 1.2) — belt-and-suspenders: if a race-class
|
|
2642
|
+
// ioctl error somehow escapes safelyResizePty's own catch (or comes
|
|
2643
|
+
// from a future write/ioctl path), downgrade to console.debug
|
|
2644
|
+
// instead of polluting stderr with the noisy ws-message-handler
|
|
2645
|
+
// error log. safelyResizePty itself already catches the resize
|
|
2646
|
+
// path; this catches any other race-class shape that bubbles here.
|
|
2647
|
+
if (isPtyRaceError(err)) {
|
|
2648
|
+
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
2649
|
+
console.debug(`[ws] message handler race-class (suppressed): ${err.code || err.message}`);
|
|
2650
|
+
}
|
|
2651
|
+
} else {
|
|
2652
|
+
console.error('[ws] message handler error:', err);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2293
2655
|
});
|
|
2294
2656
|
|
|
2295
2657
|
ws.on('close', () => {
|
|
@@ -2599,12 +2961,24 @@ module.exports = {
|
|
|
2599
2961
|
// helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
|
|
2600
2962
|
// the prior re-implementation pattern in the test could drift silently.
|
|
2601
2963
|
safelyResizePty,
|
|
2964
|
+
// Sprint 63 T1 (Item 1.2 + 1.3) — race-class classifier + raw-body hex
|
|
2965
|
+
// prefix renderer exported so fence tests can import the production
|
|
2966
|
+
// helpers instead of re-implementing them.
|
|
2967
|
+
isPtyRaceError,
|
|
2968
|
+
hexEscapePrefix,
|
|
2602
2969
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2603
2970
|
readTermdeckSecretsForPty,
|
|
2604
2971
|
_resetTermdeckSecretsCache,
|
|
2972
|
+
// Sprint 64 T1 (ORCH SCOPE 16:29 item 4) — management-token exclusion list.
|
|
2973
|
+
// Exported for `packages/cli/tests/spawn-env-exclusion.test.js` fence.
|
|
2974
|
+
SECRETS_EXCLUDED_FROM_PTY,
|
|
2605
2975
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
2606
2976
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
2607
2977
|
// payload shape, fire-and-forget).
|
|
2608
2978
|
onPanelClose,
|
|
2609
2979
|
_setSpawnSessionEndHookImplForTesting,
|
|
2980
|
+
// Sprint 64 T3.4 — periodic-capture surface (Investigation 2 closure).
|
|
2981
|
+
onPanelPeriodicCapture,
|
|
2982
|
+
_setSpawnPeriodicCaptureHookImplForTesting,
|
|
2983
|
+
_resolvePeriodicCaptureIntervalMs,
|
|
2610
2984
|
};
|
|
@@ -261,7 +261,13 @@ async function checkShellSanity() {
|
|
|
261
261
|
let output = '';
|
|
262
262
|
let resolved = false;
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
// Sprint 63 T3 §3.3 — drop `-l` (login mode). `-l` sources ~/.bash_profile
|
|
265
|
+
// / ~/.zshrc and friends, which on heavy profiles (nvm, conda, plugin
|
|
266
|
+
// managers — Brad's r730 has conda) routinely exceeds the 3s timeout
|
|
267
|
+
// budget below. A PTY-spawn health check answers "can $SHELL spawn a
|
|
268
|
+
// PTY and emit output?" — not "does the user's interactive profile
|
|
269
|
+
// complete fast?" Login-mode startup time is unrelated to PTY health.
|
|
270
|
+
const proc = ptyMod.spawn(shell, ['-c', 'echo TERMDECK_OK'], {
|
|
265
271
|
name: 'xterm-256color',
|
|
266
272
|
cols: 80,
|
|
267
273
|
rows: 24,
|