sigrank 0.12.0 → 0.12.2

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
@@ -67,7 +67,7 @@ sigrank
67
67
  ## Commands
68
68
 
69
69
  ```
70
- ⊙ SigRank CLI v0.11.2
70
+ ⊙ SigRank CLI v0.12.1
71
71
 
72
72
  Default (no args)
73
73
  sigrank unified dashboard: cascade + token pillars + board
@@ -258,7 +258,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
258
258
  | Var | Default | Description |
259
259
  |---|---|---|
260
260
  | `SIGRANK_API_BASE` | `https://signalaf.com` | Override the board host |
261
- | `SIGRANK_FETCH_TIMEOUT_MS` | `10000` | Board API fetch timeout |
261
+ | `SIGRANK_FETCH_TIMEOUT` | `10000` | Board API fetch timeout (ms) |
262
262
 
263
263
  ---
264
264
 
package/cli.mjs CHANGED
@@ -396,6 +396,16 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
396
396
 
397
397
  // ── COMPARE command ───────────────────────────────────────────────────────────
398
398
  // Side-by-side: ccusage (JSON) vs tokenpull vs token-dashboard (SQLite)
399
+ //
400
+ // NOTE (P3 2026-06-27): These sync verifier readers (ccusagePillars / tokscalePillars /
401
+ // tokenDashPillars / appPillars) are INTENTIONALLY separate from tokenpull.mjs
402
+ // `freshVerifierPillars()`. The compare command uses cached/file-based data sources
403
+ // (tokscale_report.json, direct db read with windowed+model-filtered queries) for a
404
+ // quick side-by-side, while freshVerifierPillars runs all three live (bunx tokscale,
405
+ // scan+read tokendash) for the dashboard. Merging them would change compare's data
406
+ // source and lose the windowed tokendash breakdown. ccusagePillars IS functionally
407
+ // identical to _freshCcusage and could be consolidated in a future pass if compare
408
+ // switches to live data — but that's a behavior change, not a refactor.
399
409
 
400
410
  function ccusagePillars(platform = 'claude') {
401
411
  // ccusage <platform> daily --json → sum by window
@@ -831,7 +841,7 @@ async function runSigRank() {
831
841
  writeln(` ${gold('⊙ SigRank')} ${bold('Operator Dashboard')}`)
832
842
  writeln(` ${dim('reading local data…')}`)
833
843
 
834
- const { tokenpullAny } = await import('./tokenpull.mjs')
844
+ const { tokenpullAny, freshVerifierPillars } = await import('./tokenpull.mjs')
835
845
 
836
846
  // Local sources first (fast) — board fetch with 5s timeout runs in parallel
837
847
  const boardPromise = Promise.race([
@@ -839,24 +849,16 @@ async function runSigRank() {
839
849
  new Promise(r => setTimeout(() => r(null), 5000)),
840
850
  ])
841
851
 
842
- const [platformResults, tdPillars] = await Promise.all([
852
+ // FIX A1-CLI (2026-06-27): verifiers (ccusage/tokscale/tokendash) are fetched
853
+ // ON-DEMAND for ACTIVE platforms only — not synchronously for all 15 platforms
854
+ // upfront (which blocked Dashboard paint up to ~40s, same class of bug as TUI
855
+ // FIX A1). The sync ccusagePillars()/tokscalePillars()/tokenDashPillars() loop
856
+ // over ALL_PLATFORMS is replaced by parallel freshVerifierPillars() calls after
857
+ // we know which platforms actually have local data.
858
+ const [platformResults] = await Promise.all([
843
859
  Promise.allSettled(ALL_PLATFORMS.map(p => tokenpullAny(p))),
844
- Promise.resolve(tokenDashPillars()),
845
860
  ])
846
861
 
847
- // per-platform verifiers: ccusage + tokscale both support multiple platforms
848
- // build map: platform → { cc, ts }
849
- const verifierMap = {}
850
- for (const platform of ALL_PLATFORMS) {
851
- verifierMap[platform] = {
852
- cc: ccusagePillars(platform),
853
- ts: tokscalePillars(platform),
854
- }
855
- }
856
- // keep top-level claude refs for combined section
857
- const ccPillars = verifierMap['claude']?.cc ?? null
858
- const tsPillars = verifierMap['claude']?.ts ?? null
859
-
860
862
  const boardData = await boardPromise
861
863
 
862
864
  // filter to platforms with actual data
@@ -872,6 +874,21 @@ async function runSigRank() {
872
874
  active.push(d)
873
875
  }
874
876
 
877
+ // Fetch verifiers on-demand for active platforms only (parallel, never throws).
878
+ // tdPillars (token-dashboard) is claude-only and pulled from the claude fresh result.
879
+ const verifierMap = {}
880
+ let tdPillars = null
881
+ if (active.length > 0) {
882
+ const fresh = await Promise.all(
883
+ active.map(d => freshVerifierPillars(d.platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null })))
884
+ )
885
+ for (let i = 0; i < active.length; i++) {
886
+ const f = fresh[i]
887
+ verifierMap[active[i].platform] = { cc: f.ccusage, ts: f.tokscale }
888
+ if (active[i].platform === 'claude' && f.tokendash) tdPillars = f.tokendash
889
+ }
890
+ }
891
+
875
892
  // clear the 3 loading lines (blank + header + "reading…")
876
893
  write(CURSOR_UP(3) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE)
877
894
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tokenpull.mjs CHANGED
@@ -17,13 +17,27 @@
17
17
 
18
18
  import { readdir, readFile, lstat } from 'node:fs/promises'
19
19
  import { existsSync } from 'node:fs'
20
- import { execSync } from 'node:child_process'
20
+ import { execSync, execFile } from 'node:child_process'
21
21
  import { join } from 'node:path'
22
22
  import { homedir } from 'node:os'
23
23
  import { ADAPTERS } from './adapters.mjs'
24
24
 
25
25
  const DAY_MS = 86_400_000
26
26
 
27
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
28
+ // fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
29
+ // no screen repaint for up to 90s during the tokendash scan). execFile is async: the
30
+ // event loop keeps running, so the TUI stays responsive while external commands run.
31
+ // Returns stdout as a string. Rejects on error or timeout (caller catches → null).
32
+ function execFileAsync(cmd, args, timeoutMs) {
33
+ return new Promise((resolve, reject) => {
34
+ execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
35
+ if (err) reject(err)
36
+ else resolve(stdout.toString())
37
+ })
38
+ })
39
+ }
40
+
27
41
  // Background tooling that runs under your account but is NOT your work — memory plugins,
28
42
  // observers, summarizers, sub-agent fleets. They pad output/Υ (claude-mem did ~27%).
29
43
  // Excluded so pillars reflect the operator, not their tools. EXTENSIBLE — add tool
@@ -297,9 +311,13 @@ export async function tokenpullAny(platform, opts = {}) {
297
311
 
298
312
  // ccusage: run `ccusage <platform> daily --json` and bucket the daily rows into the four
299
313
  // windows by date. Logic copied from tools.mjs `_ccusagePillars` (do NOT import private fns).
300
- function _freshCcusage(platform = 'claude') {
314
+ //
315
+ // ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
316
+ // freezing the TUI locked frame + key handling). Now uses execFile (async) so the
317
+ // event loop keeps running — the TUI stays responsive while the external command runs.
318
+ async function _freshCcusage(platform = 'claude') {
301
319
  try {
302
- const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
320
+ const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
303
321
  const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
304
322
  if (!Array.isArray(rows)) return null
305
323
  const now = Date.now()
@@ -329,24 +347,26 @@ function _freshCcusage(platform = 'claude') {
329
347
  // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
330
348
  // the live db via sqlite3. Claude only (the db only holds Claude Code sessions). The scan is
331
349
  // best-effort: if it fails (tool missing, slow), we still read whatever the db currently has.
332
- function _freshTokendash(platform = 'claude') {
350
+ //
351
+ // ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
352
+ // loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
353
+ // Now uses execFile (async) so the TUI stays responsive during the scan.
354
+ async function _freshTokendash(platform = 'claude') {
333
355
  if (platform !== 'claude') return null
334
356
  const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
335
357
  const scanCli = join(homedir(), 'Desktop', 'token-dashboard', 'cli.py')
336
358
  // Step 1 — refresh the db (best-effort; failures must not abort the read).
337
359
  if (existsSync(scanCli)) {
338
- try {
339
- execSync(`python3 "${scanCli}" scan`, { timeout: 90000, stdio: ['ignore', 'ignore', 'ignore'] })
340
- } catch { /* continue: read whatever the db currently has */ }
360
+ try { await execFileAsync('python3', [scanCli, 'scan'], 90000) }
361
+ catch { /* continue: read whatever the db currently has */ }
341
362
  }
342
363
  // Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
343
364
  if (!existsSync(dbPath)) return null
344
365
  try {
345
- const raw = execSync(
346
- `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
347
- { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
348
- ).toString().trim()
349
- const [i, o, cw, cr] = raw.split('|').map(Number)
366
+ const raw = await execFileAsync('sqlite3', [dbPath,
367
+ 'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
368
+ ], 5000)
369
+ const [i, o, cw, cr] = raw.trim().split('|').map(Number)
350
370
  if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
351
371
  return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
352
372
  } catch { return null }
@@ -358,9 +378,12 @@ function _freshTokendash(platform = 'claude') {
358
378
  // `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
359
379
  // carry no real usage). all-time only — tokscale's models view is not windowed. If no row
360
380
  // matches the platform, return null gracefully.
361
- function _freshTokscale(platform = 'claude') {
381
+ //
382
+ // ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
383
+ // Now uses execFile (async) so the TUI stays responsive.
384
+ async function _freshTokscale(platform = 'claude') {
362
385
  try {
363
- const raw = execSync(`bunx tokscale@latest models --json`, { timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
386
+ const raw = await execFileAsync('bunx', ['tokscale@latest', 'models', '--json'], 60000)
364
387
  const data = JSON.parse(raw)
365
388
  const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
366
389
  const rows = entries.filter((e) =>
package/tools.mjs CHANGED
@@ -22,6 +22,12 @@ import path from 'node:path'
22
22
 
23
23
  // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
24
  // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
25
+ //
26
+ // NOTE (P3 2026-06-27): Intentionally separate from tokenpull.mjs `freshVerifierPillars()`.
27
+ // The MCP `tokenpull_compare` tool uses these file-based/cached readers (tokscale_report.json,
28
+ // direct db read) for a quick comparison, while freshVerifierPillars runs all sources live
29
+ // (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
30
+ // different behavior; do NOT merge without understanding the trade-off.
25
31
 
26
32
  function _ccusagePillars(platform = 'claude') {
27
33
  try {
package/tui.mjs CHANGED
@@ -221,70 +221,13 @@ function sparkline(values) {
221
221
  }).join('')
222
222
  }
223
223
 
224
- // ── Data sources (same as cli.mjs) ──────────────────────────────────────────
225
- function ccusagePillars(platform = 'claude') {
226
- try {
227
- const cmd = platform === 'claude' ? 'ccusage claude daily --json' : `ccusage ${platform} daily --json`
228
- const raw = execSync(cmd, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
229
- const data = JSON.parse(raw)
230
- const rows = data.daily ?? data
231
- const now = Date.now()
232
- const cutoff = { '7d': 7, '30d': 30, '90d': 90 }
233
- const result = {}
234
- for (const [win, days] of Object.entries(cutoff)) {
235
- const since = new Date(now - days * 86400000)
236
- let i = 0, o = 0, cw = 0, cr = 0
237
- for (const row of rows) {
238
- if (new Date(row.date ?? row.day ?? '1970') >= since) {
239
- i += row.inputTokens ?? row.input_tokens ?? 0
240
- o += row.outputTokens ?? row.output_tokens ?? 0
241
- cw += row.cacheCreationTokens ?? row.cache_create_tokens ?? 0
242
- cr += row.cacheReadTokens ?? row.cache_read_tokens ?? 0
243
- }
244
- }
245
- result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
246
- }
247
- let i = 0, o = 0, cw = 0, cr = 0
248
- for (const row of rows) {
249
- i += row.inputTokens ?? 0
250
- o += row.outputTokens ?? 0
251
- cw += row.cacheCreationTokens ?? 0
252
- cr += row.cacheReadTokens ?? 0
253
- }
254
- result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
255
- return result
256
- } catch { return null }
257
- }
258
-
259
- function tokscalePillars(platform = 'claude') {
260
- const p = path.join(os.homedir(), 'tokscale_report.json')
261
- if (!existsSync(p)) return null
262
- try {
263
- const data = JSON.parse(readFileSync(p, 'utf8'))
264
- const rows = (data.entries ?? []).filter(e =>
265
- e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && (e.input > 0 || e.output > 0)
266
- )
267
- if (!rows.length) return null
268
- const acc = rows.reduce((a, e) => ({
269
- input: a.input + (e.input ?? 0), output: a.output + (e.output ?? 0),
270
- cacheCreate: a.cacheCreate + (e.cacheWrite ?? 0), cacheRead: a.cacheRead + (e.cacheRead ?? 0),
271
- }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
272
- return { all: acc }
273
- } catch { return null }
274
- }
275
-
276
- function tokenDashPillars() {
277
- const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
278
- if (!existsSync(dbPath)) return null
279
- try {
280
- const raw = execSync(
281
- `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
282
- { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }
283
- ).toString().trim()
284
- const [i, o, cw, cr] = raw.split('|').map(Number)
285
- return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
286
- } catch { return null }
287
- }
224
+ // ── Data sources ─────────────────────────────────────────────────────────────
225
+ // (0.12.1 FIX 4) The local verifier readers (ccusage / tokscale / token-dashboard)
226
+ // moved to `freshVerifierPillars()` in tokenpull.mjs — FRESH on-demand pulls used by
227
+ // the Compare tab (#9). The old in-tui copies were dead after A1 removed the Dashboard
228
+ // verifierMap loop and #9 routed Compare through the fresh helper; removed here.
229
+ // (tools.mjs keeps its own `_ccusagePillars`/`_tokscalePillars`/`_tokenDashPillars` —
230
+ // still used by the `tokenpull_compare` MCP tool.)
288
231
 
289
232
  async function loadDashboardData() {
290
233
  const { tokenpullAny } = await import('./tokenpull.mjs')
@@ -474,7 +417,40 @@ function yieldSpark(d) {
474
417
  }
475
418
 
476
419
  // ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
477
- function renderDashboard(data, status = '') {
420
+
421
+ // SCROLL-VIEW (2026-06-27): section-scoped scroll for the cascade table.
422
+ // The cascade section gets a real scroll viewport — up/down arrows scroll just
423
+ // the cascade rows while the rest of the dashboard (header, token composition,
424
+ // board, footer) stays pinned. This replaces the misleading "+N more — resize /
425
+ // scroll" text (A3) which implied scroll existed but didn't (alt-screen locked
426
+ // frame = no terminal scroll). The scroll is IN-SECTION only, on the Dashboard tab.
427
+
428
+ const DASH_WINS = ['7d', '30d', '90d', 'all']
429
+
430
+ // Count how many cascade rows the active platforms would produce (every window
431
+ // with data across all active platforms). This is the "total wanted" count.
432
+ function cascadeScrollableCount(active) {
433
+ return active.reduce((n, d) =>
434
+ n + DASH_WINS.filter(wk => {
435
+ const wd = d.windows?.find(x => x.window === wk)
436
+ return wd && (wd.pillars.input + wd.pillars.output) > 0
437
+ }).length, 0)
438
+ }
439
+
440
+ // Compute the max cascade rows that fit in the current terminal height budget.
441
+ // Mirrors the calculation in renderDashboard (budget - used - sectionsBelow).
442
+ function maxCascadeRowsFor(active) {
443
+ const budget = H() - 4 // 2 tab bar + 2 footer
444
+ const platformCount = active.length
445
+ const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0)
446
+ const boardLines = 17
447
+ const sectionsBelow = barLines + boardLines
448
+ // used before cascade = header(2) + blank(1) + title(1) + blank(1) + col-header(1) + separator(1) = 7
449
+ const usedBeforeCascade = 7
450
+ return Math.max(8, budget - usedBeforeCascade - sectionsBelow)
451
+ }
452
+
453
+ function renderDashboard(data, status = '', scrollOffset = 0) {
478
454
  const w = W()
479
455
  const budget = H() - 4 // 2 tab bar + 2 footer
480
456
  const WINS = ['7d', '30d', '90d', 'all']
@@ -554,35 +530,51 @@ function renderDashboard(data, status = '') {
554
530
  const sectionsBelow = barLines + boardLines
555
531
  const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
556
532
 
557
- // A3 (2026-06-27): count the cascade rows we WANT to show (every window with data across all
558
- // active platforms) so we can tell the user how many were dropped by the height budget, instead
559
- // of silently truncating them. (Fit logic below is unchanged — this is observation only.)
560
- const totalCascadeRowsWanted = active.reduce((n, d) =>
561
- n + WINS.filter(wk => {
562
- const wd = d.windows?.find(x => x.window === wk)
563
- return wd && (wd.pillars.input + wd.pillars.output) > 0
564
- }).length, 0)
565
-
533
+ // SCROLL-VIEW (2026-06-27): Build the full list of cascade rows first, then
534
+ // slice by [scrollOffset, scrollOffset + maxCascadeRows]. This gives a real
535
+ // in-section scroll viewport — up/down arrows scroll just the cascade rows
536
+ // while the rest of the dashboard stays pinned. Replaces the A3 "+N more —
537
+ // resize / scroll" text which implied scroll existed but didn't.
566
538
  let firstWin = {}
567
- let cascadeRowCount = 0
539
+ const allCascadeRows = [] // flat list: { platform, winKey, pillars, estimated }
568
540
  for (const d of active) {
569
541
  firstWin[d.platform] = WINS.find(wk => {
570
542
  const wd = d.windows?.find(w => w.window === wk)
571
543
  return wd && (wd.pillars.input + wd.pillars.output) > 0
572
544
  })
573
545
  for (const wk of WINS) {
574
- if (cascadeRowCount >= maxCascadeRows || used >= budget - sectionsBelow) break
575
546
  const wd = d.windows?.find(x => x.window === wk)
576
547
  if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
577
- renderRow(d.platform, (s) => wk === firstWin[d.platform] ? cyan(s) : dim(s), wk, wd.pillars, d.estimated)
578
- cascadeRowCount++
548
+ allCascadeRows.push({ platform: d.platform, winKey: wk, pillars: wd.pillars, estimated: d.estimated })
579
549
  }
580
- emit()
581
550
  }
582
551
 
583
- // A3: if any cascade rows were dropped (height budget / row cap), say so instead of hiding them.
584
- const cascadeDropped = totalCascadeRowsWanted - cascadeRowCount
585
- if (cascadeDropped > 0) emit(` ${dim(` +${cascadeDropped} more — resize / scroll`)}`)
552
+ const totalWanted = allCascadeRows.length
553
+ const canScroll = totalWanted > maxCascadeRows
554
+ // Clamp scroll offset to valid range [0, totalWanted - maxCascadeRows]
555
+ const maxOffset = Math.max(0, totalWanted - maxCascadeRows)
556
+ const effectiveOffset = Math.min(scrollOffset, maxOffset)
557
+ const visibleRows = allCascadeRows.slice(effectiveOffset, effectiveOffset + maxCascadeRows)
558
+
559
+ // ▲ indicator: rows above the current viewport
560
+ if (canScroll && effectiveOffset > 0) {
561
+ emit(` ${dim(` ▲ ${effectiveOffset} row${effectiveOffset > 1 ? 's' : ''} above`)}`)
562
+ }
563
+
564
+ // Render the visible slice — group by platform (emit a blank line between platforms)
565
+ let lastPlatform = null
566
+ for (const row of visibleRows) {
567
+ if (lastPlatform !== null && row.platform !== lastPlatform) emit()
568
+ renderRow(row.platform, (s) => row.winKey === firstWin[row.platform] ? cyan(s) : dim(s), row.winKey, row.pillars, row.estimated)
569
+ lastPlatform = row.platform
570
+ }
571
+ if (visibleRows.length > 0) emit()
572
+
573
+ // ▼ indicator: rows below the current viewport
574
+ const rowsBelow = totalWanted - effectiveOffset - visibleRows.length
575
+ if (canScroll && rowsBelow > 0) {
576
+ emit(` ${dim(` ▼ ${rowsBelow} row${rowsBelow > 1 ? 's' : ''} below · ↑↓ scroll`)}${effectiveOffset > 0 ? dim(` · ${effectiveOffset}/${maxOffset}`) : ''}`)
577
+ }
586
578
 
587
579
  // combined
588
580
  if (active.length > 1 && used < budget - sectionsBelow) {
@@ -763,7 +755,9 @@ function renderTrends(data, subIdx = 0) {
763
755
  emit()
764
756
  }
765
757
  // A3: surface the dropped count instead of hiding it.
766
- if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize / scroll`)}`)
758
+ // SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
759
+ // because these tabs don't have a real scroll viewport (only Dashboard cascade does).
760
+ if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize to see more`)}`)
767
761
  } else { // Field
768
762
  emit(` ${dim(' Field-wide trends — calculating…')}`)
769
763
  emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
@@ -939,8 +933,10 @@ function renderSubmissions(boardData, window = '30d', filterCodename = null, hig
939
933
  shown++
940
934
  }
941
935
  // A3-style: if the height budget dropped rows, say so instead of silently truncating.
936
+ // SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
937
+ // (Board tab doesn't have a real scroll viewport — only Dashboard cascade does).
942
938
  const dropped = sorted.length - shown
943
- if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize / scroll`)}`)
939
+ if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize to see more`)}`)
944
940
  }
945
941
 
946
942
  // ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
@@ -1360,6 +1356,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1360
1356
  let submitMsg = '' // [S] in-place submit result, shown in the read-tab footer
1361
1357
  let submitPreview = false // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
1362
1358
  let boardYouOnly = false // FIX I2: [Y] toggles the Board to "just me" (hybrid model)
1359
+ let cascadeScroll = 0 // SCROLL-VIEW (2026-06-27): cascade-section scroll offset (Dashboard only)
1363
1360
 
1364
1361
  // ── Redraw (buffered: renders into memory, then paints as a locked frame)
1365
1362
  const redraw = async () => {
@@ -1386,9 +1383,13 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1386
1383
  if (!dashData) {
1387
1384
  writeln(`\n ${dim('loading dashboard…')}`)
1388
1385
  } else {
1389
- renderDashboard(dashData, status)
1386
+ renderDashboard(dashData, status, cascadeScroll)
1390
1387
  }
1391
- setFooter(readFooter(`${hint}${submitHint}`))
1388
+ // SCROLL-VIEW: show ↑↓ hint only when there are cascade rows to scroll
1389
+ const scrollHint = dashData && Array.isArray(dashData.active)
1390
+ ? cascadeScrollableCount(dashData.active) > maxCascadeRowsFor(dashData.active) ? ` ${dim('↑↓ scroll cascade')}` : ''
1391
+ : ''
1392
+ setFooter(readFooter(`${hint}${scrollHint}${submitHint}`))
1392
1393
  } else if (activeTab === 1) { // Trends
1393
1394
  if (!dashData) {
1394
1395
  writeln(`\n ${dim('loading trends…')}`)
@@ -1631,6 +1632,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1631
1632
  return
1632
1633
  }
1633
1634
 
1635
+ // SCROLL-VIEW (2026-06-27): ↑/↓ (or j/k) scroll the cascade section on the
1636
+ // Dashboard tab ONLY. The rest of the dashboard stays pinned. Only active
1637
+ // when there are more cascade rows than fit in the viewport.
1638
+ if (activeTab === 0 && dashData && Array.isArray(dashData.active)) {
1639
+ const total = cascadeScrollableCount(dashData.active)
1640
+ const maxRows = maxCascadeRowsFor(dashData.active)
1641
+ if (total > maxRows) {
1642
+ const maxOff = total - maxRows
1643
+ if (key === '\x1b[A' || k === 'k') { // ↑ / k → scroll up
1644
+ cascadeScroll = Math.max(0, cascadeScroll - 1)
1645
+ await redraw(); return
1646
+ }
1647
+ if (key === '\x1b[B' || k === 'j') { // ↓ / j → scroll down
1648
+ cascadeScroll = Math.min(maxOff, cascadeScroll + 1)
1649
+ await redraw(); return
1650
+ }
1651
+ if (key === '\x1b[5~') { // PageUp → scroll up by viewport
1652
+ cascadeScroll = Math.max(0, cascadeScroll - maxRows)
1653
+ await redraw(); return
1654
+ }
1655
+ if (key === '\x1b[6~') { // PageDown → scroll down by viewport
1656
+ cascadeScroll = Math.min(maxOff, cascadeScroll + maxRows)
1657
+ await redraw(); return
1658
+ }
1659
+ }
1660
+ }
1661
+
1634
1662
  // tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
1635
1663
  let switched = false
1636
1664
  if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); switched = true }
@@ -1643,14 +1671,23 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1643
1671
  if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
1644
1672
  if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
1645
1673
 
1674
+ // SCROLL-VIEW: reset cascade scroll when leaving the Dashboard tab
1675
+ if (switched && activeTab !== 0) cascadeScroll = 0
1676
+
1646
1677
  // #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
1647
1678
  // (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
1648
1679
  // sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
1680
+ // ASYNC FIX (2026-06-27): with execFile (async), the event loop keeps running during the
1681
+ // pull — key presses are NOT blocked. Guard: only render the result if the user is still
1682
+ // on the Compare tab (they may have switched away during the pull).
1649
1683
  if (switched && activeTab === 2 && !compareData) {
1650
1684
  submitMsg = ''
1651
1685
  await redraw() // show the spinner line before the (slow) fresh pull
1652
- compareData = await loadCompareData(platform).catch(() => null)
1653
- await redraw()
1686
+ const result = await loadCompareData(platform).catch(() => null)
1687
+ if (activeTab === 2) { // still on Compare? render the result
1688
+ compareData = result
1689
+ await redraw()
1690
+ }
1654
1691
  return
1655
1692
  }
1656
1693
 
@@ -1674,8 +1711,12 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1674
1711
  compareData = null
1675
1712
  status = `loading ${platform}…`
1676
1713
  await redraw()
1677
- compareData = await loadCompareData(platform).catch(() => null)
1678
- status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1714
+ // ASYNC FIX: guard — only render if still on Compare (user may have switched away)
1715
+ const result = await loadCompareData(platform).catch(() => null)
1716
+ if (activeTab === 2) {
1717
+ compareData = result
1718
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1719
+ }
1679
1720
  } else {
1680
1721
  // A5: Watch [P] cycles 'all' → claude → codex → … → 'all' (optional focus; default 'all').
1681
1722
  const WATCH_PLAT_CYCLE = ['all', ...CYCLE_PLATFORMS]
@@ -1714,14 +1755,19 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1714
1755
 
1715
1756
  if (k === 'r') {
1716
1757
  status = 'refreshing…'
1758
+ cascadeScroll = 0 // SCROLL-VIEW: reset scroll on refresh
1717
1759
  // #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
1718
1760
  // (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
1719
1761
  if (activeTab === 2) {
1720
1762
  compareData = null
1721
1763
  await redraw()
1722
- compareData = await loadCompareData(platform).catch(() => null)
1723
- status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1724
- await redraw()
1764
+ // ASYNC FIX: guard — only render if still on Compare
1765
+ const result = await loadCompareData(platform).catch(() => null)
1766
+ if (activeTab === 2) {
1767
+ compareData = result
1768
+ status = `last refreshed ${new Date().toLocaleTimeString('en-US', { hour12: false })}`
1769
+ await redraw()
1770
+ }
1725
1771
  return
1726
1772
  }
1727
1773
  await redraw()