@jhizzard/termdeck 1.0.13 → 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/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"
|
|
@@ -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,
|
|
@@ -267,12 +267,92 @@ function _termdeckVersion() {
|
|
|
267
267
|
catch { return '0.0.0'; }
|
|
268
268
|
}
|
|
269
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
|
+
|
|
270
309
|
function createServer(config) {
|
|
271
310
|
const app = express();
|
|
272
311
|
const server = http.createServer(app);
|
|
273
312
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
274
313
|
|
|
275
|
-
|
|
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
|
+
}));
|
|
276
356
|
|
|
277
357
|
// Sprint 56 (T2 F-T2-1) — malformed-JSON body returns JSON 400, not
|
|
278
358
|
// express's default HTML error page. Pre-Sprint-56 every POST/PATCH
|
|
@@ -281,9 +361,23 @@ function createServer(config) {
|
|
|
281
361
|
// smoke tests). The status code (400) was correct; only the body
|
|
282
362
|
// shape regressed. Mounted IMMEDIATELY after express.json() so it
|
|
283
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.
|
|
284
369
|
app.use((err, req, res, next) => {
|
|
285
|
-
if (err && (
|
|
286
|
-
|
|
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
|
+
});
|
|
287
381
|
}
|
|
288
382
|
return next(err);
|
|
289
383
|
});
|
|
@@ -1489,7 +1583,10 @@ function createServer(config) {
|
|
|
1489
1583
|
|
|
1490
1584
|
const { cols, rows } = req.body;
|
|
1491
1585
|
try {
|
|
1492
|
-
|
|
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
|
+
}
|
|
1493
1590
|
res.json({ ok: true, cols, rows });
|
|
1494
1591
|
} catch (err) {
|
|
1495
1592
|
res.status(500).json({ error: err.message });
|
|
@@ -2160,9 +2257,10 @@ function createServer(config) {
|
|
|
2160
2257
|
break;
|
|
2161
2258
|
|
|
2162
2259
|
case 'resize':
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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);
|
|
2166
2264
|
break;
|
|
2167
2265
|
|
|
2168
2266
|
case 'meta':
|
|
@@ -2454,7 +2552,16 @@ if (require.main === module) {
|
|
|
2454
2552
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
2455
2553
|
|
|
2456
2554
|
server.listen(port, host, () => {
|
|
2457
|
-
|
|
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`);
|
|
2458
2565
|
console.log(` Terminals: 0 active`);
|
|
2459
2566
|
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
2460
2567
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
@@ -2470,6 +2577,10 @@ if (require.main === module) {
|
|
|
2470
2577
|
module.exports = {
|
|
2471
2578
|
createServer,
|
|
2472
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,
|
|
2473
2584
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2474
2585
|
readTermdeckSecretsForPty,
|
|
2475
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();
|