@jhizzard/termdeck 1.0.12 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +72 -7
- package/packages/cli/src/index.js +115 -2
- package/packages/cli/src/init-mnestra.js +14 -5
- package/packages/cli/src/stack.js +39 -10
- package/packages/client/public/app.js +85 -0
- package/packages/client/public/style.css +5 -0
- package/packages/server/src/agent-adapters/codex.js +18 -0
- package/packages/server/src/config.js +4 -2
- package/packages/server/src/index.js +196 -9
- package/packages/server/src/session.js +27 -1
- package/packages/server/src/setup/dotenv-io.js +12 -3
- package/packages/server/src/setup/supabase-url.js +39 -8
- package/packages/server/src/spawn-shell.js +27 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# TermDeck
|
|
2
2
|
|
|
3
|
+
[](https://github.com/jhizzard/termdeck/actions/workflows/install-smoke.yml)
|
|
4
|
+
|
|
3
5
|
> **The terminal that remembers what you fixed last month.**
|
|
4
6
|
|
|
5
7
|
A browser-based terminal multiplexer with an onboarding tour, rich per-panel metadata, and **Flashback** — automatic recall of similar past errors, surfaced the moment a panel hits a problem. No asking, no querying, no manual search. TermDeck notices you're stuck and offers the memory.
|
|
@@ -16,6 +18,8 @@ npx @jhizzard/termdeck
|
|
|
16
18
|
|
|
17
19
|
Ninety seconds, one command. Node 18+ is all you need — prebuilt binaries mean no C++ toolchain. Your browser opens automatically at `http://127.0.0.1:3000`, an onboarding tour walks you through every button, and you're launching real PTY shells, Claude Code, Python servers, or anything else a normal terminal can run.
|
|
18
20
|
|
|
21
|
+
> **Linux x64 — one-line caveat.** If `npm config get omit` returns `optional`, append `--include=optional` to any global TermDeck or `@anthropic-ai/claude-code` install (`npm install -g @jhizzard/termdeck --include=optional`). On macOS this is unnecessary. Full explanation in [docs/GETTING-STARTED.md § Linux x64 install hint](docs/GETTING-STARTED.md#linux-x64-install-hint).
|
|
22
|
+
|
|
19
23
|
This is **Tier 1**. Works immediately, fully local, no accounts, no credentials, no database. You get the full dashboard — 7 grid layouts, 8 themes, per-panel metadata overlays, terminal switcher, reply button, status logs, session history in local SQLite. **Flashback is silent at this tier** because there's no memory store to query.
|
|
20
24
|
|
|
21
25
|
First-time user? The **config** button in the toolbar shows what's set up and what's next — click it for a live view of each tier's status with guided next steps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -160,6 +160,32 @@ function _compareSemver(a, b) {
|
|
|
160
160
|
return 0;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Sprint 58 T2 (Brad #4) — Mnestra-version probe used to gate the hybrid-
|
|
164
|
+
// search RPC name in `_runSchemaCheck`. Mnestra ≤ 0.3.x exposes
|
|
165
|
+
// `search_memories(...)`; Mnestra ≥ 0.4.0 renamed it to
|
|
166
|
+
// `memory_hybrid_search(...)`. Pre-fix doctor hard-coded `search_memories`,
|
|
167
|
+
// false-flagging RED on every install at Mnestra 0.4.0+. Reuses
|
|
168
|
+
// `_detectInstalled` (the same `npm ls -g` probe the version-check section
|
|
169
|
+
// already runs). Returns the installed version string or null if not
|
|
170
|
+
// detectable. Exposed as its own module export so unit tests can monkey-
|
|
171
|
+
// patch it independently of the rest of the doctor pipeline.
|
|
172
|
+
async function _detectMnestraVersion() {
|
|
173
|
+
return module.exports._detectInstalled('@jhizzard/mnestra');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Sprint 58 T2 (Brad #4) — RPC names to probe for the Mnestra hybrid-search
|
|
177
|
+
// function, gated on the installed Mnestra version.
|
|
178
|
+
// ≥ 0.4.0 → ['memory_hybrid_search']
|
|
179
|
+
// ≤ 0.3.x → ['search_memories']
|
|
180
|
+
// null/unknown → ['memory_hybrid_search', 'search_memories'] (probe both;
|
|
181
|
+
// GREEN if either exists — graceful on offline / non-
|
|
182
|
+
// globally-installed cases).
|
|
183
|
+
function _selectHybridSearchRpcNames(mnestraVersion) {
|
|
184
|
+
if (!mnestraVersion) return ['memory_hybrid_search', 'search_memories'];
|
|
185
|
+
if (_compareSemver(mnestraVersion, '0.4.0') >= 0) return ['memory_hybrid_search'];
|
|
186
|
+
return ['search_memories'];
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
function classifyRow(installed, latest) {
|
|
164
190
|
if (latest === null) return STATUS.NETWORK_ERROR;
|
|
165
191
|
if (installed === null) return STATUS.NOT_INSTALLED;
|
|
@@ -354,13 +380,45 @@ async function _runSchemaCheck(opts = {}) {
|
|
|
354
380
|
status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
|
|
355
381
|
hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
|
|
356
382
|
});
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
383
|
+
modern.push({
|
|
384
|
+
label: `match_memories() RPC`,
|
|
385
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc('match_memories'))) ? 'pass' : 'fail',
|
|
386
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Sprint 58 T2 (Brad #4): version-gate the hybrid-search RPC name. The
|
|
390
|
+
// function was renamed `search_memories` → `memory_hybrid_search` at
|
|
391
|
+
// Mnestra 0.4.0. Pre-fix doctor probed only the legacy name, so every
|
|
392
|
+
// install at Mnestra ≥ 0.4.0 reported false-RED here. The version is
|
|
393
|
+
// passed in by `doctor()` (which already detected it for the version-
|
|
394
|
+
// check table); when called standalone, we self-detect.
|
|
395
|
+
const mnestraVersion = optsObj.mnestraVersion !== undefined
|
|
396
|
+
? optsObj.mnestraVersion
|
|
397
|
+
: await module.exports._detectMnestraVersion();
|
|
398
|
+
const hybridProbeNames = _selectHybridSearchRpcNames(mnestraVersion);
|
|
399
|
+
const hybridProbeLabel = hybridProbeNames.length === 1
|
|
400
|
+
? `${hybridProbeNames[0]}() RPC`
|
|
401
|
+
: `${hybridProbeNames.join(' or ')}() RPC`;
|
|
402
|
+
let hybridOk = false;
|
|
403
|
+
for (const name of hybridProbeNames) {
|
|
404
|
+
if (await probeSchema(client, SCHEMA_QUERIES.rpc(name))) {
|
|
405
|
+
hybridOk = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
363
408
|
}
|
|
409
|
+
modern.push({
|
|
410
|
+
label: hybridProbeLabel,
|
|
411
|
+
status: hybridOk ? 'pass' : 'fail',
|
|
412
|
+
hint: mnestraVersion
|
|
413
|
+
? `Mnestra ${mnestraVersion} expects ${hybridProbeNames[0]}() — re-run: termdeck init --mnestra --yes`
|
|
414
|
+
: `migration 005 (legacy) or 015+ (modern) creates it — re-run: termdeck init --mnestra --yes`,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
modern.push({
|
|
418
|
+
label: `memory_status_aggregation() RPC`,
|
|
419
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc('memory_status_aggregation'))) ? 'pass' : 'fail',
|
|
420
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
421
|
+
});
|
|
364
422
|
|
|
365
423
|
// Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
|
|
366
424
|
const legacy = sections[1].checks;
|
|
@@ -510,10 +568,15 @@ async function doctor(argv) {
|
|
|
510
568
|
);
|
|
511
569
|
|
|
512
570
|
// Sprint 35 T3: schema check (skippable for tests / offline runs).
|
|
571
|
+
// Sprint 58 T2 (Brad #4): pass the already-detected Mnestra version
|
|
572
|
+
// through so `_runSchemaCheck` doesn't re-shell-out to `npm ls -g`. The
|
|
573
|
+
// version-check section detected it a few lines up; reuse it.
|
|
513
574
|
let schema = null;
|
|
514
575
|
if (!opts.noSchema) {
|
|
576
|
+
const mnestraRow = rows.find((r) => r.package === '@jhizzard/mnestra');
|
|
577
|
+
const mnestraVersion = mnestraRow ? mnestraRow.installed : null;
|
|
515
578
|
try {
|
|
516
|
-
schema = await module.exports._runSchemaCheck();
|
|
579
|
+
schema = await module.exports._runSchemaCheck({ mnestraVersion });
|
|
517
580
|
} catch (err) {
|
|
518
581
|
schema = {
|
|
519
582
|
skipped: false,
|
|
@@ -559,6 +622,8 @@ module.exports = doctor;
|
|
|
559
622
|
module.exports._detectInstalled = _detectInstalled;
|
|
560
623
|
module.exports._fetchLatest = _fetchLatest;
|
|
561
624
|
module.exports._compareSemver = _compareSemver;
|
|
625
|
+
module.exports._detectMnestraVersion = _detectMnestraVersion;
|
|
626
|
+
module.exports._selectHybridSearchRpcNames = _selectHybridSearchRpcNames;
|
|
562
627
|
module.exports._runSchemaCheck = _runSchemaCheck;
|
|
563
628
|
module.exports.STACK_PACKAGES = STACK_PACKAGES;
|
|
564
629
|
module.exports.STATUS = STATUS;
|
|
@@ -14,7 +14,93 @@
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
|
-
const { exec, execSync } = require('child_process');
|
|
17
|
+
const { exec, execSync, spawn } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Sprint 59 — Brad #1 nohup-secrets bootstrap.
|
|
20
|
+
//
|
|
21
|
+
// Brad's environment: `nohup termdeck --no-stack ...` from a shell that has
|
|
22
|
+
// NOT sourced ~/.termdeck/secrets.env. In-process `setenv()` (which Node's
|
|
23
|
+
// loadSecretsEnv() uses) updates libc's `environ` pointer but does NOT
|
|
24
|
+
// propagate to /proc/<pid>/environ on Linux glibc — the kernel reads from
|
|
25
|
+
// the env_start..env_end memory range fixed at execve() time, and new keys
|
|
26
|
+
// added via setenv() get heap-allocated outside that range. A probe that
|
|
27
|
+
// introspects /proc therefore sees the empty initial env.
|
|
28
|
+
//
|
|
29
|
+
// Fix: when launched in non-TTY mode (nohup detaches stdin/stdout/stderr)
|
|
30
|
+
// AND secrets.env exists with at least one key not already in process.env,
|
|
31
|
+
// spawn a detached child node with the merged env and exit the parent. The
|
|
32
|
+
// child's /proc/<pid>/environ contains the merged keys because spawn() goes
|
|
33
|
+
// through fork+execve(), which sets the kernel env range with the new env.
|
|
34
|
+
//
|
|
35
|
+
// Guards (must ALL be true to spawn-and-exit):
|
|
36
|
+
// 1. __TERMDECK_BOOTSTRAPPED env marker absent (we're the original entry,
|
|
37
|
+
// not the re-execed child).
|
|
38
|
+
// 2. argv[0] is NOT a subcommand we hand off (`init`, `forge`, `doctor`,
|
|
39
|
+
// `stack`). Those subcommands have their own env-loading paths
|
|
40
|
+
// (init's --from-env, doctor's dotenv-io reader, stack's loadSecrets)
|
|
41
|
+
// and run interactively under piped stdio in tests / CI. Brad's bug
|
|
42
|
+
// is specifically the default server-launch path; bootstrap there.
|
|
43
|
+
// 3. neither stdout nor stderr is a TTY (interactive `termdeck` keeps the
|
|
44
|
+
// legacy in-process loadSecretsEnv path so Ctrl+C / signal handling /
|
|
45
|
+
// user-visible boot output stay attached to the user's terminal).
|
|
46
|
+
// 4. argv does NOT include --service or --non-interactive (T2 owns those
|
|
47
|
+
// flags for systemd Type=simple; that path runs in foreground so
|
|
48
|
+
// systemd's cgroup-tracked main process stays alive).
|
|
49
|
+
// 5. ~/.termdeck/secrets.env exists.
|
|
50
|
+
// 6. parsing the file yields at least one key that is NOT already in
|
|
51
|
+
// process.env (don't clobber pre-set shell vars; user env wins).
|
|
52
|
+
function maybeBootstrapAndDetach() {
|
|
53
|
+
if (process.env.__TERMDECK_BOOTSTRAPPED === '1') {
|
|
54
|
+
delete process.env.__TERMDECK_BOOTSTRAPPED;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const SKIP_SUBCOMMANDS = new Set(['init', 'forge', 'doctor', 'stack']);
|
|
59
|
+
if (argv.length > 0 && SKIP_SUBCOMMANDS.has(argv[0])) return false;
|
|
60
|
+
if (process.stdout.isTTY || process.stderr.isTTY) return false;
|
|
61
|
+
const argvSet = new Set(argv);
|
|
62
|
+
if (argvSet.has('--service') || argvSet.has('--non-interactive')) return false;
|
|
63
|
+
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
64
|
+
if (!fs.existsSync(secretsPath)) return false;
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try { raw = fs.readFileSync(secretsPath, 'utf-8'); }
|
|
68
|
+
catch (_e) { return false; }
|
|
69
|
+
|
|
70
|
+
const merged = {};
|
|
71
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
74
|
+
const eq = trimmed.indexOf('=');
|
|
75
|
+
if (eq === -1) continue;
|
|
76
|
+
const key = trimmed.slice(0, eq).trim();
|
|
77
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) continue;
|
|
78
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
79
|
+
if (val.length >= 2 && (val[0] === '"' || val[0] === "'") && val[val.length - 1] === val[0]) {
|
|
80
|
+
val = val.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
// Sprint 59 T4-CODEX residual fix: file value fills when parent env is undefined
|
|
83
|
+
// OR empty string (Brad's actual failure shape includes parent env present-but-blank,
|
|
84
|
+
// not only missing entirely). Non-empty parent env still wins.
|
|
85
|
+
if (process.env[key] === undefined || process.env[key] === '') merged[key] = val;
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(merged).length === 0) return false;
|
|
88
|
+
|
|
89
|
+
const env = { ...process.env, ...merged, __TERMDECK_BOOTSTRAPPED: '1' };
|
|
90
|
+
const child = spawn(process.execPath, [__filename, ...process.argv.slice(2)], {
|
|
91
|
+
env,
|
|
92
|
+
stdio: 'inherit',
|
|
93
|
+
detached: true,
|
|
94
|
+
});
|
|
95
|
+
child.unref();
|
|
96
|
+
// Parent exits immediately. The fixture's TD_PID points at the parent
|
|
97
|
+
// process; once the parent dies, /proc/<TD_PID>/environ becomes unreadable
|
|
98
|
+
// and the fixture's pgrep fallback finds the child (which has the merged
|
|
99
|
+
// env in its /proc/<pid>/environ via execve). The child keeps running.
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
maybeBootstrapAndDetach();
|
|
18
104
|
|
|
19
105
|
// Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
|
|
20
106
|
// TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
|
|
@@ -215,9 +301,24 @@ const noStackIdx = args.indexOf('--no-stack');
|
|
|
215
301
|
const noStackRequested = noStackIdx !== -1;
|
|
216
302
|
if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
|
|
217
303
|
|
|
304
|
+
// Sprint 59 T2 — Brad #7: --service / --non-interactive flag for systemd
|
|
305
|
+
// Type=simple deployment. When set, the launcher must (a) skip browser
|
|
306
|
+
// auto-open (no DISPLAY in service contexts), (b) bypass the auto-orchestrate
|
|
307
|
+
// child-spawn detour so ExecStart=/usr/local/bin/termdeck --service blocks
|
|
308
|
+
// for the lifetime of the server (Type=simple sees an active foreground
|
|
309
|
+
// process), (c) be tolerated everywhere `--no-stack` is. Strip both aliases
|
|
310
|
+
// repeatedly so duplicates don't survive into the flag-parsing loop.
|
|
311
|
+
let serviceMode = false;
|
|
312
|
+
while (true) {
|
|
313
|
+
const idx = args.findIndex((a) => a === '--service' || a === '--non-interactive');
|
|
314
|
+
if (idx === -1) break;
|
|
315
|
+
serviceMode = true;
|
|
316
|
+
args.splice(idx, 1);
|
|
317
|
+
}
|
|
318
|
+
|
|
218
319
|
const wantsHelp = args.includes('--help') || args.includes('-h');
|
|
219
320
|
|
|
220
|
-
if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !wantsHelp && shouldAutoOrchestrate()) {
|
|
321
|
+
if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !serviceMode && !wantsHelp && shouldAutoOrchestrate()) {
|
|
221
322
|
const stack = require(path.join(__dirname, 'stack.js'));
|
|
222
323
|
stack(args).then((code) => process.exit(code || 0)).catch((err) => {
|
|
223
324
|
console.error('[cli] auto-stack failed:', err && err.stack || err);
|
|
@@ -243,6 +344,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
243
344
|
termdeck Auto-orchestrate stack if configured, else Tier-1-only
|
|
244
345
|
termdeck stack Force boot Mnestra + check Rumen + start TermDeck
|
|
245
346
|
termdeck --no-stack Skip orchestrator (force Tier-1-only boot)
|
|
347
|
+
termdeck --service Non-interactive foreground mode for systemd Type=simple
|
|
348
|
+
(alias: --non-interactive; implies --no-stack + --no-open)
|
|
246
349
|
termdeck --port 8080 Start on custom port
|
|
247
350
|
termdeck --no-open Don't auto-open browser
|
|
248
351
|
termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
|
|
@@ -277,6 +380,16 @@ if (flags.sessionLogs) {
|
|
|
277
380
|
process.env.TERMDECK_SESSION_LOGS = '1';
|
|
278
381
|
}
|
|
279
382
|
|
|
383
|
+
// Sprint 59 T2 — Brad #7: --service implies --no-open. The browser auto-open
|
|
384
|
+
// path runs `xdg-open` / `open` which has no meaning under systemd (no
|
|
385
|
+
// DISPLAY) and would just dump a non-fatal error to stderr/journalctl every
|
|
386
|
+
// boot. Honoring serviceMode here is in addition to the auto-orchestrate
|
|
387
|
+
// bypass above — a user could pass `--no-stack --service` and we still want
|
|
388
|
+
// noOpen to win.
|
|
389
|
+
if (serviceMode) {
|
|
390
|
+
flags.noOpen = true;
|
|
391
|
+
}
|
|
392
|
+
|
|
280
393
|
// First-run detection (Sprint 19 T3): surface a one-line hint pointing at
|
|
281
394
|
// the setup wizard when no config.yaml exists yet. Check happens before
|
|
282
395
|
// loadConfig() so the message reflects on-disk state, not defaults.
|
|
@@ -119,11 +119,20 @@ function parseFlags(argv) {
|
|
|
119
119
|
function inputsFromEnv() {
|
|
120
120
|
const env = process.env;
|
|
121
121
|
const missing = [];
|
|
122
|
+
// Brad #2 (Sprint 59): strip surrounding matched quotes from each value
|
|
123
|
+
// BEFORE shape-checks. The dotenv parsers (config.js / dotenv-io.js /
|
|
124
|
+
// launcher.js readSecrets) all strip at file-read time, but `--from-env`
|
|
125
|
+
// bypasses those — Brad's reproducer exports a literal-quoted DATABASE_URL
|
|
126
|
+
// directly into the shell's environment via
|
|
127
|
+
// export DATABASE_URL="\"$TEST_DATABASE_URL\""
|
|
128
|
+
// and the leading `"` makes new URL() throw 'Invalid URL'. Strip here at
|
|
129
|
+
// the validator boundary so a quoted env-var value gets the same handling
|
|
130
|
+
// as a quoted secrets.env line.
|
|
122
131
|
const required = {
|
|
123
|
-
SUPABASE_URL: env.SUPABASE_URL,
|
|
124
|
-
SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
125
|
-
DATABASE_URL: env.DATABASE_URL,
|
|
126
|
-
OPENAI_API_KEY: env.OPENAI_API_KEY
|
|
132
|
+
SUPABASE_URL: urlHelper.stripSurroundingQuotes((env.SUPABASE_URL || '').trim()),
|
|
133
|
+
SUPABASE_SERVICE_ROLE_KEY: urlHelper.stripSurroundingQuotes((env.SUPABASE_SERVICE_ROLE_KEY || '').trim()),
|
|
134
|
+
DATABASE_URL: urlHelper.stripSurroundingQuotes((env.DATABASE_URL || '').trim()),
|
|
135
|
+
OPENAI_API_KEY: urlHelper.stripSurroundingQuotes((env.OPENAI_API_KEY || '').trim())
|
|
127
136
|
};
|
|
128
137
|
for (const [k, v] of Object.entries(required)) {
|
|
129
138
|
if (!v || !v.trim()) missing.push(k);
|
|
@@ -155,7 +164,7 @@ function inputsFromEnv() {
|
|
|
155
164
|
const oaErr = urlHelper.looksLikeOpenAiKey(required.OPENAI_API_KEY);
|
|
156
165
|
if (oaErr) throw new Error(`OPENAI_API_KEY: ${oaErr}`);
|
|
157
166
|
|
|
158
|
-
const anthropicKey = (env.ANTHROPIC_API_KEY || '').trim() || null;
|
|
167
|
+
const anthropicKey = urlHelper.stripSurroundingQuotes((env.ANTHROPIC_API_KEY || '').trim()) || null;
|
|
159
168
|
if (anthropicKey) {
|
|
160
169
|
const aErr = urlHelper.looksLikeAnthropicKey(anthropicKey);
|
|
161
170
|
if (aErr) {
|
|
@@ -432,7 +432,7 @@ async function checkRumen() {
|
|
|
432
432
|
|
|
433
433
|
// ── Step 4: TermDeck ────────────────────────────────────────────────
|
|
434
434
|
|
|
435
|
-
function execTermDeck({ port, extra }) {
|
|
435
|
+
function execTermDeck({ port, extra }, deps = {}) {
|
|
436
436
|
// Spawn a fresh node process for the CLI rather than require()-ing it
|
|
437
437
|
// in-process. Two reasons:
|
|
438
438
|
// 1. require() hits Node's module cache after stack.js → index.js →
|
|
@@ -443,22 +443,45 @@ function execTermDeck({ port, extra }) {
|
|
|
443
443
|
// stack.js tried to re-require the (cached) CLI — silent exit.
|
|
444
444
|
// 2. Pass --no-stack on the way back so index.js definitively skips
|
|
445
445
|
// the auto-orchestrate detection. Defensive even with the spawn.
|
|
446
|
+
//
|
|
447
|
+
// Sprint 59 T2 — Brad #7: returns Promise<exitCode> that resolves when the
|
|
448
|
+
// child exits. Pre-Sprint-59 the function returned undefined synchronously,
|
|
449
|
+
// so main() returned 0 immediately and `process.exit(0)` fired before the
|
|
450
|
+
// child had bound the port. Under `systemd Type=simple`, ExecStart=
|
|
451
|
+
// /usr/local/bin/termdeck "succeeded" in milliseconds and the cgroup tore
|
|
452
|
+
// down the orphaned child — service stuck inactive even though everything
|
|
453
|
+
// looked fine to the operator. The await in main() now blocks ExecStart
|
|
454
|
+
// for the lifetime of the child server. `deps` exposes spawn + signals as
|
|
455
|
+
// injection points for tests/launcher-service-flag.test.js.
|
|
456
|
+
const _spawn = deps.spawn || spawn;
|
|
457
|
+
const _signals = deps.signals || process;
|
|
446
458
|
const cliPath = path.join(__dirname, 'index.js');
|
|
447
459
|
const argv = [cliPath, '--no-stack'];
|
|
448
460
|
if (port) argv.push('--port', String(port));
|
|
449
461
|
argv.push(...extra);
|
|
450
|
-
const child =
|
|
462
|
+
const child = _spawn(process.execPath, argv, {
|
|
451
463
|
stdio: 'inherit',
|
|
452
464
|
env: process.env,
|
|
453
465
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
});
|
|
458
|
-
// Forward Ctrl+C cleanly so the spawned server can shut down.
|
|
466
|
+
// Forward Ctrl+C / systemd-stop signals cleanly so the child can flush
|
|
467
|
+
// before exit. Listeners are attached to the parent's signal source so
|
|
468
|
+
// tests can simulate signal delivery without raising real signals.
|
|
459
469
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
460
|
-
|
|
470
|
+
_signals.on(sig, () => { try { child.kill(sig); } catch (_e) { /* gone */ } });
|
|
461
471
|
}
|
|
472
|
+
return new Promise((resolve) => {
|
|
473
|
+
child.on('exit', (code, signal) => {
|
|
474
|
+
if (signal) {
|
|
475
|
+
// Re-raise the signal on the parent so the caller (systemd / shell)
|
|
476
|
+
// sees the right termination state, then resolve so main() can
|
|
477
|
+
// unwind.
|
|
478
|
+
try { _signals.kill(_signals.pid, signal); } catch (_e) { /* fallthrough */ }
|
|
479
|
+
resolve(code == null ? 0 : code);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
resolve(code == null ? 0 : code);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
462
485
|
}
|
|
463
486
|
|
|
464
487
|
// ── Main ────────────────────────────────────────────────────────────
|
|
@@ -514,8 +537,11 @@ async function main(rawArgs) {
|
|
|
514
537
|
stepLine('4/4', 'Starting TermDeck', 'BOOT', `(port ${port})`);
|
|
515
538
|
process.stdout.write(`\n ${ANSI.bold}Stack:${ANSI.reset} ${ANSI.green}${summary.join(' | ')}${ANSI.reset}\n\n`);
|
|
516
539
|
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
// Sprint 59 T2 — Brad #7: await the child so ExecStart blocks for the
|
|
541
|
+
// child's full lifetime. Without the await, `Type=simple` units saw
|
|
542
|
+
// ExecStart return 0 in milliseconds and tore the service down.
|
|
543
|
+
const exitCode = await execTermDeck({ port, extra: args.extra });
|
|
544
|
+
return exitCode;
|
|
519
545
|
}
|
|
520
546
|
|
|
521
547
|
module.exports = function (argv) {
|
|
@@ -532,3 +558,6 @@ module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
|
|
|
532
558
|
module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
|
|
533
559
|
module.exports.CLAUDE_MCP_PATHS = CLAUDE_MCP_PATHS;
|
|
534
560
|
module.exports.hasMnestraMcpEntry = hasMnestraMcpEntry;
|
|
561
|
+
// Sprint 59 T2 — Brad #7: exposed for tests/launcher-service-flag.test.js so
|
|
562
|
+
// the wait-semantics fix can be verified without a real Node child spawn.
|
|
563
|
+
module.exports._execTermDeck = execTermDeck;
|
|
@@ -177,6 +177,15 @@
|
|
|
177
177
|
panel.addEventListener('drop', (e) => {
|
|
178
178
|
e.preventDefault();
|
|
179
179
|
panel.classList.remove('drag-over');
|
|
180
|
+
panel.classList.remove('file-drop-active');
|
|
181
|
+
// External file drop (zip / image / any binary) → upload + type @path.
|
|
182
|
+
// Detected when dataTransfer.files has entries and there's no internal panel drag.
|
|
183
|
+
const files = e.dataTransfer && e.dataTransfer.files;
|
|
184
|
+
const hasInternalDrag = !!document.querySelector('.term-panel.dragging');
|
|
185
|
+
if (!hasInternalDrag && files && files.length > 0) {
|
|
186
|
+
uploadFilesAndType(panel, Array.from(files));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
180
189
|
const draggedId = (() => {
|
|
181
190
|
try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
|
|
182
191
|
})();
|
|
@@ -188,6 +197,82 @@
|
|
|
188
197
|
const dropAfter = (e.clientX - rect.left) > rect.width / 2;
|
|
189
198
|
panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
|
|
190
199
|
});
|
|
200
|
+
|
|
201
|
+
// Sprint 59 scope-expansion (Brad's "drop a zip into Codex" question 2026-05-07):
|
|
202
|
+
// file drop and clipboard image paste upload to /api/sessions/:id/upload, then type
|
|
203
|
+
// @<path> via the existing /input endpoint so the agent (Claude/Codex/Gemini/Grok)
|
|
204
|
+
// sees the standard @filepath attachment syntax.
|
|
205
|
+
panel.addEventListener('dragover', (e) => {
|
|
206
|
+
const types = (e.dataTransfer && e.dataTransfer.types) || [];
|
|
207
|
+
const hasFiles = Array.from(types).includes('Files');
|
|
208
|
+
if (!hasFiles) return;
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
try { e.dataTransfer.dropEffect = 'copy'; } catch (_e) {}
|
|
211
|
+
panel.classList.add('file-drop-active');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
panel.addEventListener('dragleave', (e) => {
|
|
215
|
+
if (!panel.contains(e.relatedTarget)) panel.classList.remove('file-drop-active');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
panel.addEventListener('paste', (e) => {
|
|
219
|
+
const items = (e.clipboardData && e.clipboardData.items) || [];
|
|
220
|
+
const blobs = [];
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
|
223
|
+
const blob = item.getAsFile();
|
|
224
|
+
if (blob) blobs.push(blob);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (blobs.length === 0) return;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
230
|
+
const named = blobs.map((b, i) => {
|
|
231
|
+
const ext = (b.type.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
|
|
232
|
+
const name = b.name && b.name.length > 0
|
|
233
|
+
? b.name
|
|
234
|
+
: `pasted-${ts}${blobs.length > 1 ? '-' + i : ''}.${ext}`;
|
|
235
|
+
return new File([b], name, { type: b.type });
|
|
236
|
+
});
|
|
237
|
+
uploadFilesAndType(panel, named);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function uploadFilesAndType(panel, files) {
|
|
242
|
+
const sessionId = panel.id.replace(/^panel-/, '');
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
try {
|
|
245
|
+
const url = `/api/sessions/${sessionId}/upload?name=${encodeURIComponent(file.name)}`;
|
|
246
|
+
const buf = await file.arrayBuffer();
|
|
247
|
+
const res = await fetch(url, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
250
|
+
credentials: 'same-origin',
|
|
251
|
+
body: buf,
|
|
252
|
+
});
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
const errText = await res.text().catch(() => '');
|
|
255
|
+
console.error('[upload] failed', res.status, errText);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
// Type "@<path> " into the panel via the existing /input endpoint so the
|
|
260
|
+
// shape matches a manually-typed @filepath. The trailing space lets the
|
|
261
|
+
// user keep typing the rest of their prompt.
|
|
262
|
+
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: { 'Content-Type': 'application/json' },
|
|
265
|
+
credentials: 'same-origin',
|
|
266
|
+
body: JSON.stringify({ text: `@${data.path} `, source: 'file-drop' }),
|
|
267
|
+
});
|
|
268
|
+
if (!inputRes.ok) {
|
|
269
|
+
const errText = await inputRes.text().catch(() => '');
|
|
270
|
+
console.error('[upload] file uploaded but typing failed', inputRes.status, errText);
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('[upload] exception', err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
191
276
|
}
|
|
192
277
|
|
|
193
278
|
// ===== Create Terminal Panel =====
|
|
@@ -413,6 +413,11 @@
|
|
|
413
413
|
outline: 2px solid var(--tg-accent);
|
|
414
414
|
outline-offset: -2px;
|
|
415
415
|
}
|
|
416
|
+
.term-panel.file-drop-active {
|
|
417
|
+
outline: 2px dashed var(--tg-accent);
|
|
418
|
+
outline-offset: -2px;
|
|
419
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.05) inset;
|
|
420
|
+
}
|
|
416
421
|
|
|
417
422
|
.status-dot {
|
|
418
423
|
width: 8px; height: 8px;
|
|
@@ -69,6 +69,17 @@ const TOOL = /^(?:\$\s|→\s|exec(?:_command\b|\b)|Running\b|Calling\b)/m;
|
|
|
69
69
|
// label when it's done reasoning and waiting on the user.
|
|
70
70
|
const IDLE = /^codex\s*$/m;
|
|
71
71
|
|
|
72
|
+
// End-of-turn terminator (Sprint 60 v1.0.14 fix). After Codex finishes a
|
|
73
|
+
// reply the TUI renders a separator with the elapsed time, e.g.
|
|
74
|
+
// "─ Worked for 2m 50s ──────────" using box-drawing dashes (U+2500). This
|
|
75
|
+
// pattern is unambiguous: it only ever appears when the turn closes and the
|
|
76
|
+
// panel parks waiting for next input. Placed FIRST in the statusFor cascade
|
|
77
|
+
// because the same chunk may also contain a final "Working" spinner update
|
|
78
|
+
// that would otherwise stick `status: 'thinking'` indefinitely. Bit Sprint 59
|
|
79
|
+
// twice — orchestrator's `meta.status` reported "Codex is reasoning..." for
|
|
80
|
+
// 22+ minutes after Codex actually parked at end-of-turn.
|
|
81
|
+
const END_OF_TURN = /─\s*Worked for\s+(?:\d+m\s*)?\d+s\s*─/;
|
|
82
|
+
|
|
72
83
|
// Error patterns — line-anchored to avoid mid-line "error" mentions in tool
|
|
73
84
|
// output (grep results, test logs, file dumps) flagging false positives.
|
|
74
85
|
// Same shape as Claude with codex-specific OpenAI-API failure modes added
|
|
@@ -82,6 +93,12 @@ const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|F
|
|
|
82
93
|
// ──────────────────────────────────────────────────────────────────────────
|
|
83
94
|
|
|
84
95
|
function statusFor(data) {
|
|
96
|
+
// Sprint 60 v1.0.14: end-of-turn terminator wins over THINKING. Without
|
|
97
|
+
// this branch, a chunk that contains both a final "Working Xs" spinner
|
|
98
|
+
// line AND the closing "Worked for X" separator would stick on 'thinking'.
|
|
99
|
+
if (END_OF_TURN.test(data)) {
|
|
100
|
+
return { status: 'idle', statusDetail: '' };
|
|
101
|
+
}
|
|
85
102
|
if (THINKING.test(data)) {
|
|
86
103
|
return { status: 'thinking', statusDetail: 'Codex is reasoning...' };
|
|
87
104
|
}
|
|
@@ -261,6 +278,7 @@ const codexAdapter = {
|
|
|
261
278
|
patterns: {
|
|
262
279
|
prompt: PROMPT,
|
|
263
280
|
thinking: THINKING,
|
|
281
|
+
endOfTurn: END_OF_TURN,
|
|
264
282
|
editing: EDITING,
|
|
265
283
|
tool: TOOL,
|
|
266
284
|
idle: IDLE,
|
|
@@ -59,8 +59,10 @@ function loadSecretsEnv() {
|
|
|
59
59
|
const parsed = parseDotenv(raw);
|
|
60
60
|
const keys = [];
|
|
61
61
|
for (const [k, v] of Object.entries(parsed)) {
|
|
62
|
-
// Do not clobber pre-set process env; shell wins.
|
|
63
|
-
|
|
62
|
+
// Do not clobber pre-set process env; shell wins. Sprint 59 T4-CODEX residual
|
|
63
|
+
// fix: also fill when parent env is empty string (Brad's actual failure shape
|
|
64
|
+
// includes DATABASE_URL= in the parent service environment, not only missing).
|
|
65
|
+
if (process.env[k] === undefined || process.env[k] === '') {
|
|
64
66
|
process.env[k] = v;
|
|
65
67
|
}
|
|
66
68
|
keys.push(k);
|
|
@@ -82,6 +82,7 @@ const orchestrationPreview = require('./orchestration-preview');
|
|
|
82
82
|
const { createPtyReaper } = require('./pty-reaper');
|
|
83
83
|
const { AGENT_ADAPTERS } = require('./agent-adapters');
|
|
84
84
|
const { deriveRagMode } = require('./rag-mode');
|
|
85
|
+
const { resolveSpawnShell } = require('./spawn-shell');
|
|
85
86
|
|
|
86
87
|
// Sprint 48 T4 deliverable 2: PTY env-var propagation.
|
|
87
88
|
// Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
|
|
@@ -266,12 +267,92 @@ function _termdeckVersion() {
|
|
|
266
267
|
catch { return '0.0.0'; }
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
// Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
|
|
271
|
+
// forensic surfaced 25× `[ws] message handler error: Error: ioctl(2) failed,
|
|
272
|
+
// EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
|
|
273
|
+
// that pty-reaper has already closed (or the child has exited), and
|
|
274
|
+
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
275
|
+
// but the noisy console.error trace pollutes diagnostics and obscures real
|
|
276
|
+
// errors. This helper guards against the race and downgrades the known
|
|
277
|
+
// race-class errors (EBADF, ENOTTY, generic "ioctl failed" message shape) to
|
|
278
|
+
// a silent return. Set TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug
|
|
279
|
+
// for diagnostics.
|
|
280
|
+
function safelyResizePty(session, cols, rows) {
|
|
281
|
+
if (!session || !session.pty) return false;
|
|
282
|
+
if (session.meta && session.meta.status === 'exited') return false;
|
|
283
|
+
try {
|
|
284
|
+
session.pty.resize(cols || 120, rows || 30);
|
|
285
|
+
return true;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const msg = (err && err.message) || '';
|
|
288
|
+
const code = err && err.code;
|
|
289
|
+
// Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
|
|
290
|
+
// requires explicit EBADF or ENOTTY (in code OR message). The earlier
|
|
291
|
+
// shape — any "ioctl(N) failed" message — was too broad: it would have
|
|
292
|
+
// silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
|
|
293
|
+
// might indicate a real bug. Now: only the specific race-class signals
|
|
294
|
+
// get suppressed; everything else rethrows so it surfaces in logs.
|
|
295
|
+
const isRace =
|
|
296
|
+
code === 'EBADF' ||
|
|
297
|
+
code === 'ENOTTY' ||
|
|
298
|
+
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
299
|
+
if (isRace) {
|
|
300
|
+
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
301
|
+
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code || msg}`);
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
269
309
|
function createServer(config) {
|
|
270
310
|
const app = express();
|
|
271
311
|
const server = http.createServer(app);
|
|
272
312
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
273
313
|
|
|
274
|
-
|
|
314
|
+
// Sprint 60 v1.0.14 (Item 2) — pre-screen incoming JSON bodies for unescaped
|
|
315
|
+
// control characters in string contexts. Brad's 2026-05-07 r730 crash
|
|
316
|
+
// forensic logged 9x `SyntaxError: Bad control character in string literal
|
|
317
|
+
// in JSON at position 9` per 13h uptime. The post-Sprint-56 error-handler
|
|
318
|
+
// already returns a structured 400, but body-parser's internal
|
|
319
|
+
// `JSON.parse(body)` throws a verbose SyntaxError whose 10-line stack trace
|
|
320
|
+
// dumps to stderr (Express dev-mode default error logger). The verify
|
|
321
|
+
// callback below fails earlier with a tight ControlCharBodyError that our
|
|
322
|
+
// handler logs as a single-line warning instead of a stack trace.
|
|
323
|
+
//
|
|
324
|
+
// Most likely source of these bodies: agent-to-agent inject through
|
|
325
|
+
// /api/sessions/:id/input where the `text` field contains raw PTY escape
|
|
326
|
+
// sequences (e.g. one panel forwarding terminal output to another). The
|
|
327
|
+
// 400 response is the correct user-facing semantic; this just quiets the
|
|
328
|
+
// logs so real errors aren't drowned in noise.
|
|
329
|
+
app.use(express.json({
|
|
330
|
+
verify: (req, res, buf) => {
|
|
331
|
+
// O(N) single-pass scan. Only checks bytes inside double-quoted string
|
|
332
|
+
// regions so structural whitespace doesn't trigger false positives.
|
|
333
|
+
let inString = false;
|
|
334
|
+
let escape = false;
|
|
335
|
+
for (let i = 0; i < buf.length; i++) {
|
|
336
|
+
const b = buf[i];
|
|
337
|
+
if (!inString) {
|
|
338
|
+
if (b === 0x22) inString = true; // "
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (escape) { escape = false; continue; }
|
|
342
|
+
if (b === 0x5c) { escape = true; continue; } // backslash
|
|
343
|
+
if (b === 0x22) { inString = false; continue; } // closing quote
|
|
344
|
+
// JSON forbids unescaped control chars (0x00-0x1F and 0x7F) inside
|
|
345
|
+
// string literals. Reject with a structured error.
|
|
346
|
+
if (b < 0x20 || b === 0x7f) {
|
|
347
|
+
const err = new Error(`Body contains illegal control character 0x${b.toString(16).padStart(2, '0')} at byte ${i}`);
|
|
348
|
+
err.type = 'entity.verify.failed';
|
|
349
|
+
err.statusCode = 400;
|
|
350
|
+
err.code = 'CONTROL_CHAR_IN_STRING';
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}));
|
|
275
356
|
|
|
276
357
|
// Sprint 56 (T2 F-T2-1) — malformed-JSON body returns JSON 400, not
|
|
277
358
|
// express's default HTML error page. Pre-Sprint-56 every POST/PATCH
|
|
@@ -280,9 +361,23 @@ function createServer(config) {
|
|
|
280
361
|
// smoke tests). The status code (400) was correct; only the body
|
|
281
362
|
// shape regressed. Mounted IMMEDIATELY after express.json() so it
|
|
282
363
|
// catches body-parse errors before any route handler runs.
|
|
364
|
+
//
|
|
365
|
+
// Sprint 60 v1.0.14 — extended to also catch `entity.verify.failed` from
|
|
366
|
+
// the control-char pre-screen above, AND to log via console.warn (single
|
|
367
|
+
// line) instead of letting Express's default error logger dump a 10-line
|
|
368
|
+
// stack trace to stderr.
|
|
283
369
|
app.use((err, req, res, next) => {
|
|
284
|
-
if (err && (
|
|
285
|
-
|
|
370
|
+
if (err && (
|
|
371
|
+
err.type === 'entity.parse.failed' ||
|
|
372
|
+
err.type === 'entity.verify.failed' ||
|
|
373
|
+
err instanceof SyntaxError
|
|
374
|
+
)) {
|
|
375
|
+
console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path})`);
|
|
376
|
+
return res.status(400).json({
|
|
377
|
+
error: 'Malformed JSON body',
|
|
378
|
+
detail: err.message,
|
|
379
|
+
code: err.code,
|
|
380
|
+
});
|
|
286
381
|
}
|
|
287
382
|
return next(err);
|
|
288
383
|
});
|
|
@@ -325,6 +420,26 @@ function createServer(config) {
|
|
|
325
420
|
if (orphaned.changes > 0) {
|
|
326
421
|
console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
|
|
327
422
|
}
|
|
423
|
+
// Sprint 59 T4-CODEX cleanup: reap upload tempdirs whose owning session is
|
|
424
|
+
// exited or unknown (crashed processes, hard kills, pre-this-version dirs).
|
|
425
|
+
try {
|
|
426
|
+
const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads');
|
|
427
|
+
if (fs.existsSync(uploadsRoot)) {
|
|
428
|
+
const liveIds = new Set();
|
|
429
|
+
try {
|
|
430
|
+
for (const row of db.prepare('SELECT id FROM sessions WHERE exited_at IS NULL').all()) {
|
|
431
|
+
liveIds.add(row.id);
|
|
432
|
+
}
|
|
433
|
+
} catch (_e) { /* live-set empty → all dirs are stale */ }
|
|
434
|
+
let reaped = 0;
|
|
435
|
+
for (const dir of fs.readdirSync(uploadsRoot)) {
|
|
436
|
+
if (!liveIds.has(dir)) {
|
|
437
|
+
try { fs.rmSync(path.join(uploadsRoot, dir), { recursive: true, force: true }); reaped++; } catch (_e) {}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (reaped > 0) console.log(`[uploads] Reaped ${reaped} stale upload tempdir(s)`);
|
|
441
|
+
}
|
|
442
|
+
} catch (_err) { /* non-blocking */ }
|
|
328
443
|
console.log('[db] SQLite initialized');
|
|
329
444
|
} catch (err) {
|
|
330
445
|
console.warn('[db] SQLite init failed:', err.message);
|
|
@@ -955,7 +1070,13 @@ function createServer(config) {
|
|
|
955
1070
|
const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
|
|
956
1071
|
const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
|
|
957
1072
|
|
|
958
|
-
|
|
1073
|
+
// Sprint 59 T2 — Brad #5: resolveSpawnShell chains config.shell →
|
|
1074
|
+
// $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
|
|
1075
|
+
// `apt remove zsh`) still spawns a working interactive shell instead of
|
|
1076
|
+
// failing silently from execvp(/bin/zsh).
|
|
1077
|
+
const spawnShell = isPlainShell
|
|
1078
|
+
? cmdTrim
|
|
1079
|
+
: resolveSpawnShell('', config.shell, process.env.SHELL);
|
|
959
1080
|
const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
960
1081
|
|
|
961
1082
|
try {
|
|
@@ -1042,6 +1163,14 @@ function createServer(config) {
|
|
|
1042
1163
|
onPanelClose(session).catch((err) => {
|
|
1043
1164
|
console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
|
|
1044
1165
|
});
|
|
1166
|
+
|
|
1167
|
+
// Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
|
|
1168
|
+
// per-session upload tempdir so dropped files don't outlive the panel
|
|
1169
|
+
// that received them. Fire-and-forget; never blocks teardown.
|
|
1170
|
+
try {
|
|
1171
|
+
const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1172
|
+
fs.rmSync(sessUploadDir, { recursive: true, force: true });
|
|
1173
|
+
} catch (_err) { /* non-blocking */ }
|
|
1045
1174
|
});
|
|
1046
1175
|
|
|
1047
1176
|
// Wire command logging to SQLite + RAG
|
|
@@ -1292,6 +1421,47 @@ function createServer(config) {
|
|
|
1292
1421
|
res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
|
|
1293
1422
|
});
|
|
1294
1423
|
|
|
1424
|
+
// POST /api/sessions/:id/upload?name=<filename> - File drop / clipboard image paste
|
|
1425
|
+
// Body: raw octet-stream of the file content (max 50MB).
|
|
1426
|
+
// Writes to /tmp/termdeck-uploads/<sessionId>/<sanitizedName>, returns {ok, path, name, size}.
|
|
1427
|
+
// Client typically follows up with POST /api/sessions/:id/input { text: "@<path> " } so
|
|
1428
|
+
// the agent (Claude/Codex/Gemini/Grok) sees the standard @filepath attachment syntax.
|
|
1429
|
+
// Added Sprint 59 (2026-05-07) to close Brad's "how do I drop a zip into Codex" gap.
|
|
1430
|
+
app.post('/api/sessions/:id/upload',
|
|
1431
|
+
express.raw({ type: '*/*', limit: '50mb' }),
|
|
1432
|
+
(req, res) => {
|
|
1433
|
+
const session = sessions.get(req.params.id);
|
|
1434
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1435
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
1436
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const rawName = (req.query.name || '').toString();
|
|
1440
|
+
if (!rawName) return res.status(400).json({ error: 'Missing ?name=' });
|
|
1441
|
+
// Sanitize: strip path traversal + control chars; cap at 200 chars.
|
|
1442
|
+
// Replace anything not alphanumeric / dash / underscore / dot / space with _
|
|
1443
|
+
const safeName = rawName
|
|
1444
|
+
.replace(/[\x00-\x1f\x7f/\\]/g, '_')
|
|
1445
|
+
.replace(/^\.+/, '_')
|
|
1446
|
+
.replace(/\.\.+/g, '_')
|
|
1447
|
+
.slice(0, 200) || 'upload.bin';
|
|
1448
|
+
|
|
1449
|
+
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
1450
|
+
return res.status(400).json({ error: 'Empty body' });
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1454
|
+
try {
|
|
1455
|
+
fs.mkdirSync(uploadsRoot, { recursive: true, mode: 0o700 });
|
|
1456
|
+
const fullPath = path.join(uploadsRoot, safeName);
|
|
1457
|
+
fs.writeFileSync(fullPath, req.body, { mode: 0o600 });
|
|
1458
|
+
res.json({ ok: true, path: fullPath, name: safeName, size: req.body.length });
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
return res.status(500).json({ error: err.message });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1295
1465
|
// POST /api/sessions/:id/poke - PTY-flush recovery endpoint
|
|
1296
1466
|
// Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
|
|
1297
1467
|
// Used to recover from the post-stop PTY delivery gap where injected input via /input
|
|
@@ -1413,7 +1583,10 @@ function createServer(config) {
|
|
|
1413
1583
|
|
|
1414
1584
|
const { cols, rows } = req.body;
|
|
1415
1585
|
try {
|
|
1416
|
-
|
|
1586
|
+
const resized = safelyResizePty(session, cols, rows);
|
|
1587
|
+
if (!resized) {
|
|
1588
|
+
return res.status(409).json({ error: 'Session is exited or its PTY is no longer alive' });
|
|
1589
|
+
}
|
|
1417
1590
|
res.json({ ok: true, cols, rows });
|
|
1418
1591
|
} catch (err) {
|
|
1419
1592
|
res.status(500).json({ error: err.message });
|
|
@@ -2084,9 +2257,10 @@ function createServer(config) {
|
|
|
2084
2257
|
break;
|
|
2085
2258
|
|
|
2086
2259
|
case 'resize':
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2260
|
+
// Sprint 60 v1.0.14 — safelyResizePty guards against the
|
|
2261
|
+
// pty-reaper-closed-the-fd race that surfaced 25x in Brad's
|
|
2262
|
+
// 13h uptime as ioctl EBADF/ENOTTY noise.
|
|
2263
|
+
safelyResizePty(session, parsed.cols, parsed.rows);
|
|
2090
2264
|
break;
|
|
2091
2265
|
|
|
2092
2266
|
case 'meta':
|
|
@@ -2378,7 +2552,16 @@ if (require.main === module) {
|
|
|
2378
2552
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
2379
2553
|
|
|
2380
2554
|
server.listen(port, host, () => {
|
|
2381
|
-
|
|
2555
|
+
// Sprint 60 v1.0.14 (Item 5) — per-boot banner with ISO timestamp + PID.
|
|
2556
|
+
// Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
|
|
2557
|
+
// through May 7 with only ONE boot banner at the top. Crash → restart
|
|
2558
|
+
// dropped its own banner somewhere we couldn't find, making post-mortem
|
|
2559
|
+
// diagnosis harder. Per-boot timestamps make crash boundaries trivially
|
|
2560
|
+
// greppable and let `journalctl`/`tail` users scan a single log to find
|
|
2561
|
+
// the most recent restart instantly.
|
|
2562
|
+
const bootIso = new Date().toISOString();
|
|
2563
|
+
console.log(`\n ════ TermDeck server boot · ${bootIso} · pid ${process.pid} ════`);
|
|
2564
|
+
console.log(` TermDeck running at http://${host}:${port}\n`);
|
|
2382
2565
|
console.log(` Terminals: 0 active`);
|
|
2383
2566
|
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
2384
2567
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
@@ -2394,6 +2577,10 @@ if (require.main === module) {
|
|
|
2394
2577
|
module.exports = {
|
|
2395
2578
|
createServer,
|
|
2396
2579
|
loadConfig,
|
|
2580
|
+
// Sprint 60 v1.0.14 (Item 3) — exported so tests can import the production
|
|
2581
|
+
// helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
|
|
2582
|
+
// the prior re-implementation pattern in the test could drift silently.
|
|
2583
|
+
safelyResizePty,
|
|
2397
2584
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2398
2585
|
readTermdeckSecretsForPty,
|
|
2399
2586
|
_resetTermdeckSecretsCache,
|
|
@@ -516,10 +516,29 @@ class Session {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
toJSON() {
|
|
519
|
+
const meta = { ...this.meta };
|
|
520
|
+
// Sprint 60 v1.0.14 — stale-status guard. If a panel's status is in the
|
|
521
|
+
// sticky set ('thinking', 'editing') but no PTY output has arrived for
|
|
522
|
+
// STALE_STATUS_THRESHOLD_MS, treat it as parked at end-of-turn and report
|
|
523
|
+
// 'idle' instead. Lazy: only evaluated on serialization (zero timer cost).
|
|
524
|
+
// Backstops adapter-specific end-of-turn detection — Codex's "Worked for"
|
|
525
|
+
// terminator catches the precise case; this catches the general one
|
|
526
|
+
// (Claude's stuck-on-thinking, future adapters that forget end-of-turn,
|
|
527
|
+
// any adapter where the terminator chunk is split across reads). Bit
|
|
528
|
+
// Sprint 59 twice — orchestrator's GET /api/sessions reported sticky
|
|
529
|
+
// 'thinking' for 22 minutes after the panel actually parked.
|
|
530
|
+
const STICKY_STATUSES = Session.STICKY_STATUSES;
|
|
531
|
+
if (STICKY_STATUSES.has(meta.status)) {
|
|
532
|
+
const ageMs = Date.now() - new Date(meta.lastActivity).getTime();
|
|
533
|
+
if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
|
|
534
|
+
meta.status = 'idle';
|
|
535
|
+
meta.statusDetail = '';
|
|
536
|
+
}
|
|
537
|
+
}
|
|
519
538
|
return {
|
|
520
539
|
id: this.id,
|
|
521
540
|
pid: this.pid,
|
|
522
|
-
meta
|
|
541
|
+
meta
|
|
523
542
|
};
|
|
524
543
|
}
|
|
525
544
|
|
|
@@ -530,6 +549,13 @@ class Session {
|
|
|
530
549
|
}
|
|
531
550
|
}
|
|
532
551
|
|
|
552
|
+
// Sprint 60 v1.0.14 — class statics for the stale-status guard. Exposed on
|
|
553
|
+
// the class (not const-locked inside toJSON) so tests can stub them and so
|
|
554
|
+
// the threshold can be tuned in one place if signal/noise needs adjustment.
|
|
555
|
+
Session.STICKY_STATUSES = new Set(['thinking', 'editing']);
|
|
556
|
+
Session.STALE_STATUS_THRESHOLD_MS = 30000;
|
|
557
|
+
|
|
558
|
+
|
|
533
559
|
class SessionManager {
|
|
534
560
|
constructor(db) {
|
|
535
561
|
this.sessions = new Map();
|
|
@@ -40,13 +40,22 @@ function readSecretsRaw(filepath = SECRETS_PATH) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Escape a value for safe re-serialization. Wraps in double quotes if the
|
|
43
|
-
// value contains whitespace, `#`, or
|
|
44
|
-
//
|
|
43
|
+
// value contains whitespace, `#`, or a quote char. `=` was previously in the
|
|
44
|
+
// regex but excluded after Sprint 59 Brad #2 — every Postgres URL with query
|
|
45
|
+
// params (e.g. `?sslmode=require`) contains `=`, and dotenv splits a line on
|
|
46
|
+
// the FIRST `=` only, so subsequent `=` chars in the value need no quoting.
|
|
47
|
+
// Quoting URLs broke the "writer must never add surrounding quotes to a
|
|
48
|
+
// DATABASE_URL" contract; the value still round-tripped because every reader
|
|
49
|
+
// strips matching quotes, but a downstream consumer that sourced the file
|
|
50
|
+
// via `set -a; . secrets.env` and didn't strip would see a literal-quoted
|
|
51
|
+
// value re-introduced into process.env. Keeping the regex tight to actual
|
|
52
|
+
// dotenv ambiguities (whitespace, `#` for comments, embedded quote chars)
|
|
53
|
+
// avoids that round-trip-but-not-quite class of bug at the source.
|
|
45
54
|
function formatValue(value) {
|
|
46
55
|
if (value == null) return '';
|
|
47
56
|
const str = String(value);
|
|
48
57
|
if (str === '') return '';
|
|
49
|
-
const needsQuoting = /[\s#"'
|
|
58
|
+
const needsQuoting = /[\s#"']/.test(str);
|
|
50
59
|
if (!needsQuoting) return str;
|
|
51
60
|
const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
52
61
|
return `"${escaped}"`;
|
|
@@ -9,6 +9,26 @@
|
|
|
9
9
|
// - init-rumen needs the project ref to run `supabase link --project-ref`
|
|
10
10
|
// and to substitute into the pg_cron schedule SQL.
|
|
11
11
|
|
|
12
|
+
// Brad #2 (Sprint 59) — strip ONE pair of matched surrounding single OR
|
|
13
|
+
// double quotes from a string. Idempotent: a value with no quotes returns
|
|
14
|
+
// unchanged, mismatched quotes (`"foo'`, `bar"`) return unchanged. The
|
|
15
|
+
// dotenv parsers in config.js / dotenv-io.js / launcher.js already strip
|
|
16
|
+
// at file-read time, but Brad's reproducer ships the literal-quoted value
|
|
17
|
+
// through process.env (shell `export DATABASE_URL="\"$URL\""`), bypassing
|
|
18
|
+
// the file parsers entirely. Adding the strip here defends the validator
|
|
19
|
+
// boundary so any caller that hands us a quoted env-var value gets the
|
|
20
|
+
// same handling as a quoted secrets.env line.
|
|
21
|
+
function stripSurroundingQuotes(value) {
|
|
22
|
+
if (typeof value !== 'string') return value;
|
|
23
|
+
if (value.length < 2) return value;
|
|
24
|
+
const first = value[0];
|
|
25
|
+
const last = value[value.length - 1];
|
|
26
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
27
|
+
return value.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
// A Supabase project URL looks like:
|
|
13
33
|
// https://<project-ref>.supabase.co
|
|
14
34
|
// The ref is 20 characters of lowercase alphanumerics, but we accept anything
|
|
@@ -17,7 +37,7 @@ function parseProjectUrl(url) {
|
|
|
17
37
|
if (!url || typeof url !== 'string') {
|
|
18
38
|
return { ok: false, error: 'empty url' };
|
|
19
39
|
}
|
|
20
|
-
const trimmed = url.trim().replace(/\/+$/, '');
|
|
40
|
+
const trimmed = stripSurroundingQuotes(url.trim()).replace(/\/+$/, '');
|
|
21
41
|
let u;
|
|
22
42
|
try {
|
|
23
43
|
u = new URL(trimmed);
|
|
@@ -82,9 +102,10 @@ function looksLikeAnthropicKey(key) {
|
|
|
82
102
|
// and direct connection URLs (`postgres://postgres:...@db.<ref>.supabase.co:5432/postgres`).
|
|
83
103
|
function looksLikePostgresUrl(url) {
|
|
84
104
|
if (!url || typeof url !== 'string') return 'empty';
|
|
105
|
+
const stripped = stripSurroundingQuotes(url.trim());
|
|
85
106
|
let u;
|
|
86
107
|
try {
|
|
87
|
-
u = new URL(
|
|
108
|
+
u = new URL(stripped);
|
|
88
109
|
} catch (_err) {
|
|
89
110
|
return 'not a valid URL';
|
|
90
111
|
}
|
|
@@ -137,16 +158,25 @@ function isTransactionPoolerUrl(parsedUrl) {
|
|
|
137
158
|
// because validation is the caller's job (looksLikePostgresUrl handles that).
|
|
138
159
|
function normalizeDatabaseUrl(url) {
|
|
139
160
|
if (!url || typeof url !== 'string') return { url, modified: false };
|
|
161
|
+
// Brad #2: strip surrounding quotes silently — `modified` stays scoped
|
|
162
|
+
// to "appended pgbouncer params" so the caller's user-facing message
|
|
163
|
+
// ("Detected transaction pooler URL — appending ...") doesn't fire for
|
|
164
|
+
// a no-op quote strip. The strip itself is reflected in the returned
|
|
165
|
+
// `url` so downstream `new URL(normalized.url)` / pg.Pool consumers
|
|
166
|
+
// don't re-throw.
|
|
167
|
+
const stripped = stripSurroundingQuotes(url.trim());
|
|
140
168
|
let u;
|
|
141
169
|
try {
|
|
142
|
-
u = new URL(
|
|
170
|
+
u = new URL(stripped);
|
|
143
171
|
} catch (_err) {
|
|
144
|
-
return { url, modified: false };
|
|
172
|
+
return { url: stripped, modified: false };
|
|
145
173
|
}
|
|
146
|
-
if (!isTransactionPoolerUrl(u)) return { url, modified: false };
|
|
174
|
+
if (!isTransactionPoolerUrl(u)) return { url: stripped, modified: false };
|
|
147
175
|
|
|
148
|
-
// Already has pgbouncer set? Don't touch
|
|
149
|
-
|
|
176
|
+
// Already has pgbouncer set? Don't touch — but still return the stripped URL,
|
|
177
|
+
// not the original (Sprint 59 T4-CODEX residual fix: pre-fix returned `url`,
|
|
178
|
+
// which would re-leak surrounding quotes from a quoted-pgbouncer-URL secrets.env).
|
|
179
|
+
if (u.searchParams.has('pgbouncer')) return { url: stripped, modified: false };
|
|
150
180
|
|
|
151
181
|
u.searchParams.set('pgbouncer', 'true');
|
|
152
182
|
// Set connection_limit only if not already set — preserve user intent.
|
|
@@ -171,5 +201,6 @@ module.exports = {
|
|
|
171
201
|
looksLikePostgresUrl,
|
|
172
202
|
isTransactionPoolerUrl,
|
|
173
203
|
normalizeDatabaseUrl,
|
|
174
|
-
maskSecret
|
|
204
|
+
maskSecret,
|
|
205
|
+
stripSurroundingQuotes
|
|
175
206
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Sprint 59 T2 — PTY shell fallback chain helper (Brad #5).
|
|
2
|
+
//
|
|
3
|
+
// Pre-Sprint-59 the call site at packages/server/src/index.js:958 was:
|
|
4
|
+
// const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
|
|
5
|
+
// Three failure modes converged on minimal Linux: (a) config.shell empty/unread
|
|
6
|
+
// because the YAML key was wiped or never set, (b) $SHELL ignored entirely,
|
|
7
|
+
// (c) /bin/zsh absent on the host. Result was a silent
|
|
8
|
+
// `execvp(3) failed: No such file or directory` from pty.spawn. The user's
|
|
9
|
+
// login shell was bypassed.
|
|
10
|
+
//
|
|
11
|
+
// /bin/sh is universally present on POSIX; /bin/zsh is not. The chain is:
|
|
12
|
+
// explicit cmdTrim → user's config.shell → $SHELL → /bin/sh universal floor.
|
|
13
|
+
// Caller (index.js) still owns the isPlainShell vs. -c branching; this helper
|
|
14
|
+
// only resolves the FALLBACK chain for the !isPlainShell branch (and for any
|
|
15
|
+
// future caller that wants a single-source-of-truth shell pick).
|
|
16
|
+
//
|
|
17
|
+
// The function intentionally treats "" and undefined identically — both
|
|
18
|
+
// participate in the falsy-OR chain. That matches how config.shell ends up
|
|
19
|
+
// empty when the user has `shell:` (no value) in ~/.termdeck/config.yaml,
|
|
20
|
+
// and how process.env.SHELL is undefined on container-like environments
|
|
21
|
+
// that strip the inherited shell var.
|
|
22
|
+
|
|
23
|
+
function resolveSpawnShell(cmdTrim, configShell, envShell) {
|
|
24
|
+
return cmdTrim || configShell || envShell || '/bin/sh';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { resolveSpawnShell };
|