@jhizzard/termdeck 1.4.0 → 1.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TermDeck
2
2
 
3
- [![Install Smoke](https://github.com/jhizzard/termdeck/workflows/install-smoke/badge.svg)](https://github.com/jhizzard/termdeck/actions/workflows/install-smoke.yml)
3
+ [![CI](https://github.com/jhizzard/termdeck/actions/workflows/ci.yml/badge.svg)](https://github.com/jhizzard/termdeck/actions/workflows/ci.yml)
4
4
 
5
5
  > **The terminal that remembers what you fixed last month.**
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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"
@@ -39,15 +39,13 @@
39
39
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
40
40
  "better-sqlite3": "^12.9.0",
41
41
  "chalk": "^5.3.0",
42
- "express": "^4.18.2",
43
- "open": "^10.0.0",
42
+ "express": "^5.2.1",
44
43
  "pg": "^8.20.0",
45
- "uuid": "^13.0.0",
46
44
  "ws": "^8.16.0",
47
45
  "yaml": "^2.3.4"
48
46
  },
49
47
  "devDependencies": {
50
- "@anthropic-ai/sdk": "^0.39.0"
48
+ "@anthropic-ai/sdk": "^0.96.0"
51
49
  },
52
50
  "keywords": [
53
51
  "terminal",
@@ -164,8 +164,8 @@ function reclaimStalePort(port) {
164
164
  try { process.kill(parseInt(pid, 10), 'SIGKILL'); } catch (_e) {}
165
165
  }
166
166
  } else {
167
- console.error(`\n \x1b[31m✗ Port ${port} is in use by a non-TermDeck process (PIDs: ${pids.join(' ')})\x1b[0m`);
168
- console.error(` \x1b[2mTry a different port: termdeck --port ${port + 1}\x1b[0m\n`);
167
+ process.stderr.write(`\n \x1b[31m✗ Port ${port} is in use by a non-TermDeck process (PIDs: ${pids.join(' ')})\x1b[0m\n`);
168
+ process.stderr.write(` \x1b[2mTry a different port: termdeck --port ${port + 1}\x1b[0m\n\n`);
169
169
  process.exit(1);
170
170
  }
171
171
  }
@@ -438,6 +438,7 @@
438
438
  <a class="theme-reset" id="theme-reset-${id}" href="javascript:void(0)" onclick="resetTheme('${id}')" title="Revert to project / global default from config.yaml" style="font-size:11px;color:#7aa2f7;text-decoration:none;margin-left:4px;opacity:0.7;cursor:pointer">↺ default</a>
439
439
  <button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
440
440
  <button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
441
+ <button class="ctrl-btn orch-toggle${isOrchestratorRole(meta.role) ? ' is-orch' : ''}" id="orch-toggle-${id}" type="button" onclick="toggleOrchestratorRole('${id}')" title="${isOrchestratorRole(meta.role) ? 'Unmark this panel as the orchestrator' : 'Mark this panel as the orchestrator — gold border, ORCH badge, pinned row'}">${orchToggleLabel(meta.role)}</button>
441
442
  <button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
442
443
  <input type="text" class="ctrl-input" id="ai-${id}" placeholder="Ask about this terminal..." onkeydown="if(event.key==='Enter')askAI('${id}', this.value)">
443
444
  </div>
@@ -729,11 +730,17 @@
729
730
  return panelProject === selectedFilter;
730
731
  }
731
732
 
732
- // The chip row is only worth showing when there is real filtering to do:
733
- // at least two project buckets, or one project plus some untagged panels.
733
+ // Sprint 66 T1 (Task 1.1) the chip row renders whenever there is at
734
+ // least one project bucket, so the project-filter feature is *discoverable*
735
+ // rather than hidden until a second project shows up. Brad's 2026-05-13 v2
736
+ // spec asked for an always-visible rail; his single-live-panel setup sat
737
+ // below the old ≥2 threshold and saw nothing. With one project the row is
738
+ // [ All ] + that one project chip — harmless, and it advertises the filter.
739
+ // `hasNullProject` is retained in the signature for call-site / test
740
+ // compatibility; with ≥1 project the row shows regardless of it, and with
741
+ // zero projects an All-only row carries no filter value so it stays hidden.
734
742
  function shouldShowChipRow(projects, hasNullProject) {
735
- const n = (projects || []).length;
736
- return n >= 2 || (n >= 1 && hasNullProject === true);
743
+ return (projects || []).length >= 1;
737
744
  }
738
745
 
739
746
  // Approach A (Brad's 2026-05-13 spec): orchestrator identity is the
@@ -742,6 +749,21 @@
742
749
  return role === 'orchestrator';
743
750
  }
744
751
 
752
+ // Sprint 66 T1 (Task 1.3) — the binary "mark / unmark orchestrator" toggle.
753
+ // nextRoleForToggle: the role the toggle moves a panel TO, given its
754
+ // current role — orchestrator ⇄ unroled (null). A worker/reviewer/auditor
755
+ // panel is "not orchestrator", so the toggle promotes it to orchestrator;
756
+ // it does NOT preserve a prior non-orch role (the affordance is a binary
757
+ // ORCH switch, not a role-history stack — it matches "mark / unmark as
758
+ // orchestrator"). orchToggleLabel: the toggle button's text for a role.
759
+ // Both pure — unit-tested in tests/dashboard-panels-client.test.js.
760
+ function nextRoleForToggle(currentRole) {
761
+ return currentRole === 'orchestrator' ? null : 'orchestrator';
762
+ }
763
+ function orchToggleLabel(role) {
764
+ return role === 'orchestrator' ? 'unmark orch' : 'mark orch';
765
+ }
766
+
745
767
  // Belt-and-suspenders for missed panel_exited frames: panel ids the
746
768
  // dashboard still has a tile for, but which no longer appear in the
747
769
  // server's broadcast session list. Works whether or not T2 filters exited
@@ -912,10 +934,15 @@
912
934
  }
913
935
  }
914
936
 
915
- // 1.2 — defensive reconcile: keep every tile in the container its role
916
- // dictates. meta.role is immutable post-spawn so this is a no-op after a
917
- // panel's first placement; it only acts if a panel was somehow built
918
- // before its role was known. Returns true if any tile moved.
937
+ // 1.2 — reconcile: keep every tile in the container its role dictates.
938
+ // Re-evaluates isOrchestratorRole() for every panel on each call, so it is
939
+ // the primary mover whenever a role CHANGES not merely a placement
940
+ // safety net. Sprint 66 T1 (Task 1.2) made meta.role mutable post-spawn
941
+ // (PATCH /api/sessions/:id {role}); a role flip arrives via status_broadcast,
942
+ // updatePanelMeta() merges it into entry.session.meta, scheduleChromeRefresh()
943
+ // runs this, and the panel moves into / out of the ORCH row carrying the
944
+ // panel--role-orch class (the gold border + "ORCH " badge are pure CSS on
945
+ // that class). Returns true if any tile moved.
919
946
  function reconcileOrchRow() {
920
947
  const orchRow = document.getElementById('orch-pin-row');
921
948
  const grid = document.getElementById('termGrid');
@@ -2177,6 +2204,64 @@
2177
2204
  if (sel && sel.value !== resolved) sel.value = resolved;
2178
2205
  }
2179
2206
 
2207
+ // Sprint 66 T1 (Task 1.3) — mark / unmark a LIVE panel as the orchestrator
2208
+ // in place. Brad's existing orchestrator panel was spawned with no role and
2209
+ // there was no way to set one short of destroy+recreate via the raw API.
2210
+ // This PATCHes meta.role (the Task 1.2 endpoint); on success the panel
2211
+ // moves into the pinned ORCH row and gains the gold border + "ORCH " badge
2212
+ // with no reload — reconcileOrchRow() (via refreshDashboardChrome) moves it.
2213
+ // Multi-orchestrator is allowed: marking panel B does not unmark panel A
2214
+ // (the ORCH row holds more than one; the operator explicitly unmarks). A
2215
+ // global function — invoked from the Overview-tab button's inline onclick.
2216
+ async function toggleOrchestratorRole(id) {
2217
+ const entry = state.sessions.get(id);
2218
+ if (!entry || entry._mounting || !entry.session) return;
2219
+ const current = entry.session.meta ? entry.session.meta.role : null;
2220
+ const next = nextRoleForToggle(current);
2221
+ const btn = document.getElementById(`orch-toggle-${id}`);
2222
+ if (btn) btn.disabled = true;
2223
+ try {
2224
+ const updated = await api('PATCH', `/api/sessions/${id}`, { role: next });
2225
+ // api() returns the parsed body; a non-2xx body is annotated with
2226
+ // `.error` (annotateApiFailure). The toggle only ever sends a
2227
+ // whitelisted value so a 400 should not occur — but a 404 (panel gone)
2228
+ // or a network failure can, and must not be applied as success.
2229
+ if (updated && updated.error) {
2230
+ console.error('[client] orchestrator-role toggle failed:', updated.error);
2231
+ return;
2232
+ }
2233
+ // Apply the authoritative server role from the PATCH response, then
2234
+ // re-route + re-skin the panel. The 2s status_broadcast converges to
2235
+ // the same value (eventually-consistent — same model as changeTheme).
2236
+ if (entry.session.meta) {
2237
+ entry.session.meta.role = (updated && updated.meta) ? updated.meta.role : next;
2238
+ }
2239
+ refreshDashboardChrome();
2240
+ } catch (err) {
2241
+ console.error('[client] orchestrator-role toggle error:', err);
2242
+ } finally {
2243
+ if (btn) btn.disabled = false;
2244
+ syncOrchToggle(id);
2245
+ }
2246
+ }
2247
+
2248
+ // Sprint 66 T1 (Task 1.3) — keep a panel's orch-toggle button in sync with
2249
+ // its current meta.role (label, active class, tooltip). Called after a
2250
+ // toggle and on every status_broadcast (updatePanelMeta), so the button is
2251
+ // correct even when the role is changed from another dashboard tab.
2252
+ function syncOrchToggle(id) {
2253
+ const entry = state.sessions.get(id);
2254
+ const btn = document.getElementById(`orch-toggle-${id}`);
2255
+ if (!entry || entry._mounting || !entry.session || !btn) return;
2256
+ const role = entry.session.meta ? entry.session.meta.role : null;
2257
+ const isOrch = isOrchestratorRole(role);
2258
+ btn.textContent = orchToggleLabel(role);
2259
+ btn.classList.toggle('is-orch', isOrch);
2260
+ btn.title = isOrch
2261
+ ? 'Unmark this panel as the orchestrator'
2262
+ : 'Mark this panel as the orchestrator — gold border, ORCH badge, pinned row';
2263
+ }
2264
+
2180
2265
  async function askAI(id, question) {
2181
2266
  if (!question.trim()) return;
2182
2267
  const entry = state.sessions.get(id);
@@ -3405,6 +3490,11 @@
3405
3490
  entry.session.meta = { ...entry.session.meta, ...meta };
3406
3491
  }
3407
3492
 
3493
+ // Sprint 66 T1 (Task 1.3) — re-sync the orch-toggle button from the just-
3494
+ // merged role, so a role changed from another dashboard tab is reflected
3495
+ // here too (the per-tab toggle path syncs in its own finally block).
3496
+ syncOrchToggle(id);
3497
+
3408
3498
  const dot = document.getElementById(`dot-${id}`);
3409
3499
  const status = document.getElementById(`status-${id}`);
3410
3500
  const metaLast = document.getElementById(`meta-last-${id}`);
@@ -694,6 +694,15 @@
694
694
  .ctrl-btn:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
695
695
  .ctrl-btn.active { color: var(--tg-accent); border-color: var(--tg-accent-dim); }
696
696
 
697
+ /* Sprint 66 T1 (Task 1.3) — the "mark / unmark orchestrator" toggle in the
698
+ Overview controls. When the panel IS the orchestrator the button takes
699
+ the gold/amber accent — the same hue as the ORCH-pin border + badge — so
700
+ the control and the panel treatment read as one feature. */
701
+ .ctrl-btn.orch-toggle.is-orch {
702
+ color: var(--tg-accent-orch, #d4a017);
703
+ border-color: var(--tg-accent-orch, #d4a017);
704
+ }
705
+
697
706
  .theme-select {
698
707
  background: var(--tg-bg);
699
708
  border: 1px solid var(--tg-border);
@@ -10,7 +10,6 @@ const os = require('os');
10
10
  const fs = require('fs');
11
11
  const dns = require('dns');
12
12
  const { spawn: spawnChild } = require('child_process');
13
- const { v4: uuidv4 } = require('uuid');
14
13
  const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resilience');
15
14
 
16
15
  // Conditional imports (graceful fallback if not installed yet)
@@ -29,7 +28,7 @@ try {
29
28
  console.error('[db] better-sqlite3 native ABI mismatch (Node was upgraded after install).');
30
29
  console.error('[db] TermDeck cannot serve memory features without a working SQLite.');
31
30
  console.error('[db] Fix:');
32
- console.error(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3');
31
+ process.stderr.write(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3\n');
33
32
  console.error('[db] Then restart TermDeck. Aborting.');
34
33
  process.exit(1);
35
34
  }
@@ -210,13 +209,13 @@ function _defaultSpawnSessionEndHookImpl(hookPath, payload, env) {
210
209
  env,
211
210
  });
212
211
  child.on('error', (err) => {
213
- console.error('[onPanelClose] hook spawn error:', err && err.message ? err.message : err);
212
+ console.error('[panel-close] hook spawn error:', err && err.message ? err.message : err);
214
213
  });
215
214
  try {
216
215
  child.stdin.write(JSON.stringify(payload));
217
216
  child.stdin.end();
218
217
  } catch (err) {
219
- console.error('[onPanelClose] hook stdin write failed:', err && err.message ? err.message : err);
218
+ console.error('[panel-close] hook stdin write failed:', err && err.message ? err.message : err);
220
219
  }
221
220
  child.unref();
222
221
  return child;
@@ -292,7 +291,7 @@ async function onPanelClose(session) {
292
291
  ...readTermdeckSecretsForPty(),
293
292
  });
294
293
  } catch (err) {
295
- console.error('[onPanelClose] error:', err && err.message ? err.message : err);
294
+ console.error('[panel-close] error:', err && err.message ? err.message : err);
296
295
  }
297
296
  }
298
297
 
@@ -367,7 +366,7 @@ async function onPanelPeriodicCapture(session) {
367
366
  session._periodicCapture.lastSize = stat.size;
368
367
  session._periodicCapture.lastFireMs = Date.now();
369
368
  } catch (err) {
370
- console.error('[onPanelPeriodicCapture] error:', err && err.message ? err.message : err);
369
+ console.error('[periodic-capture] error:', err && err.message ? err.message : err);
371
370
  }
372
371
  }
373
372
 
@@ -424,7 +423,7 @@ function _getT2DestFor() {
424
423
 
425
424
  function _termdeckVersion() {
426
425
  try { return require('../../../package.json').version; }
427
- catch { return '0.0.0'; }
426
+ catch (err) { console.error('[version] package.json read failed:', err); return '0.0.0'; }
428
427
  }
429
428
 
430
429
  // Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
@@ -432,7 +431,7 @@ function _termdeckVersion() {
432
431
  // EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
433
432
  // that pty-reaper has already closed (or the child has exited), and
434
433
  // `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
435
- // but the noisy console.error trace pollutes diagnostics and obscures real
434
+ // but the noisy stderr trace pollutes diagnostics and obscures real
436
435
  // errors. This helper guards against the race and downgrades the known
437
436
  // race-class errors (EBADF, ENOTTY) to a silent return. Set
438
437
  // TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
@@ -1457,7 +1456,7 @@ function createServer(config) {
1457
1456
  session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
1458
1457
  session._periodicCapture.timer = setInterval(() => {
1459
1458
  onPanelPeriodicCapture(session).catch((err) => {
1460
- console.error('[onPanelPeriodicCapture] async error:', err && err.message ? err.message : err);
1459
+ console.error('[periodic-capture] async error:', err && err.message ? err.message : err);
1461
1460
  });
1462
1461
  }, intervalMs);
1463
1462
  // Don't keep the event loop alive solely for this timer — the PTY
@@ -1549,7 +1548,7 @@ function createServer(config) {
1549
1548
  // skip-claude + skip-when-no-transcript. Fire-and-forget; any
1550
1549
  // error logs and never blocks teardown.
1551
1550
  onPanelClose(session).catch((err) => {
1552
- console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
1551
+ console.error('[panel-close] async error:', err && err.message ? err.message : err);
1553
1552
  });
1554
1553
 
1555
1554
  // Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
@@ -1754,7 +1753,19 @@ function createServer(config) {
1754
1753
 
1755
1754
  // PATCH /api/sessions/:id - update session metadata
1756
1755
  app.patch('/api/sessions/:id', (req, res) => {
1757
- const session = sessions.updateMeta(req.params.id, req.body);
1756
+ // Sprint 66 T1 (Task 1.2) — `role` is PATCH-mutable so an operator can tag
1757
+ // a live panel as orchestrator in place. Validate it exactly as POST
1758
+ // /api/sessions does (index.js — the `invalid_role` 400 above): an absent
1759
+ // field is fine, any present value must be in ALLOWED_SESSION_ROLES
1760
+ // (orchestrator/worker/reviewer/auditor/null) — an unknown value is a 400
1761
+ // so a typo surfaces immediately rather than silently mis-tagging the
1762
+ // panel. Validation runs BEFORE updateMeta so a bad role never reaches the
1763
+ // whitelist apply or the SQLite write.
1764
+ const body = req.body || {};
1765
+ if (body.role !== undefined && !ALLOWED_SESSION_ROLES.includes(body.role)) {
1766
+ return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
1767
+ }
1768
+ const session = sessions.updateMeta(req.params.id, body);
1758
1769
  if (!session) return res.status(404).json({ error: 'Session not found' });
1759
1770
  res.json(session.toJSON());
1760
1771
  });
@@ -2023,7 +2034,7 @@ function createServer(config) {
2023
2034
  return res.status(410).json({ error: 'PTY is gone (session exited)' });
2024
2035
  }
2025
2036
 
2026
- const { cols, rows } = req.body;
2037
+ const { cols, rows } = req.body || {};
2027
2038
  try {
2028
2039
  const resized = safelyResizePty(session, cols, rows);
2029
2040
  if (!resized) {
@@ -2603,7 +2614,7 @@ function createServer(config) {
2603
2614
 
2604
2615
  // POST /api/ai/query - query Mnestra memory via the bridge (direct|webhook|mcp)
2605
2616
  app.post('/api/ai/query', async (req, res) => {
2606
- let { question, sessionId, project } = req.body;
2617
+ let { question, sessionId, project } = req.body || {};
2607
2618
  if (!question) return res.status(400).json({ error: 'Missing question' });
2608
2619
 
2609
2620
  let searchAll = false;
@@ -2758,8 +2769,9 @@ function createServer(config) {
2758
2769
  });
2759
2770
  }, 2000);
2760
2771
 
2761
- // Fallback route → serve index.html
2762
- app.get('*', (req, res) => {
2772
+ // Fallback route → serve index.html. Express 5: named wildcard '/{*splat}'
2773
+ // (path-to-regexp v8 — a bare '*' throws at registration; this matches all paths incl. root).
2774
+ app.get('/{*splat}', (req, res) => {
2763
2775
  res.sendFile(path.join(clientDir, 'index.html'));
2764
2776
  });
2765
2777
 
@@ -187,7 +187,7 @@ async function generateScaffolding({ name, projects, cwd, force, initProject, te
187
187
  // Refuse on existing non-empty dir without force, mirroring T2's CLI semantics.
188
188
  if (fs.existsSync(targetPath)) {
189
189
  const entries = (() => {
190
- try { return fs.readdirSync(targetPath); } catch { return []; }
190
+ try { return fs.readdirSync(targetPath); } catch (err) { console.error('[orch-preview] readdir failed:', err); return []; }
191
191
  })();
192
192
  const nonEmpty = entries.length > 0;
193
193
  if (nonEmpty && !force) {
@@ -10,7 +10,7 @@
10
10
  // metadata broadcast in index.js untouched — `s.meta.theme` already returns
11
11
  // the right thing whenever index.js dereferences it.
12
12
 
13
- const { v4: uuidv4 } = require('uuid');
13
+ const { randomUUID } = require('crypto');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const { resolveTheme } = require('./theme-resolver');
@@ -135,7 +135,7 @@ const PATTERNS = {
135
135
 
136
136
  class Session {
137
137
  constructor(options) {
138
- this.id = options.id || uuidv4();
138
+ this.id = options.id || randomUUID();
139
139
  this.pid = null;
140
140
  this.pty = null;
141
141
  this.ws = null;
@@ -634,7 +634,15 @@ class SessionManager {
634
634
  'label',
635
635
  'project',
636
636
  'ragEnabled',
637
- 'flashbackEnabled'
637
+ 'flashbackEnabled',
638
+ // Sprint 66 T1 (Task 1.2) — `role` is now PATCH-mutable so an operator can
639
+ // tag a live panel as orchestrator in place (Brad's existing orch panel
640
+ // was spawned with no role and had no way to set one short of a raw-API
641
+ // destroy + recreate). The PATCH /api/sessions/:id route validates the
642
+ // value against ALLOWED_SESSION_ROLES before this whitelist is consulted —
643
+ // the same "route validates, model trusts" boundary as POST /api/sessions
644
+ // and the Session constructor.
645
+ 'role'
638
646
  ]);
639
647
 
640
648
  updateMeta(id, updates) {
@@ -657,6 +665,16 @@ class SessionManager {
657
665
  .run(applied.theme == null ? null : applied.theme, id);
658
666
  }
659
667
 
668
+ // Sprint 66 T1 (Task 1.2) — persist a role change to SQLite so a panel
669
+ // tagged orchestrator via PATCH keeps the role across a server restart /
670
+ // dashboard reload, exactly as a spawn-time role does. create() writes the
671
+ // `role` column on INSERT; this is its UPDATE counterpart. The column was
672
+ // added by Sprint 65 T2 (CREATE TABLE + a PRAGMA-guarded ALTER migration).
673
+ if ('role' in applied && this.db) {
674
+ this.db.prepare('UPDATE sessions SET role = ? WHERE id = ?')
675
+ .run(applied.role == null ? null : applied.role, id);
676
+ }
677
+
660
678
  this._emit('session:updated', session);
661
679
  return session;
662
680
  }
@@ -233,7 +233,7 @@ async function injectSprintPrompts({
233
233
  } else {
234
234
  anyPending = true;
235
235
  }
236
- } catch {
236
+ } catch (_err) {
237
237
  anyPending = true;
238
238
  }
239
239
  }
@@ -257,7 +257,7 @@ async function injectSprintPrompts({
257
257
  const s = await getStatus(lane.sessionId);
258
258
  lane.finalStatus = s && s.status ? s.status : lane.finalStatus;
259
259
  if (lane.finalStatus === 'thinking') lane.verified = true;
260
- } catch {
260
+ } catch (_err) {
261
261
  // ignore
262
262
  }
263
263
  }