@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.13",
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
- app.use(express.json());
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 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
286
- return res.status(400).json({ error: 'Malformed JSON body', detail: err.message });
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
- session.pty.resize(cols || 120, rows || 30);
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
- if (session.pty) {
2164
- session.pty.resize(parsed.cols || 120, parsed.rows || 30);
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
- console.log(`\n TermDeck running at http://${host}:${port}\n`);
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: { ...this.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();