dankgrinder 7.83.0 → 8.1.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/lib/grinder.js CHANGED
@@ -3,7 +3,7 @@ const Redis = require('ioredis');
3
3
  const commands = require('./commands');
4
4
  const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/utils');
5
5
  const rawLogger = require('./rawLogger');
6
- const terminal = require('./terminal');
6
+ const { renderDashboard: renderDashboardImpl } = require('./dashboard');
7
7
  const {
8
8
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
9
9
  AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
@@ -95,20 +95,10 @@ const c = {
95
95
  };
96
96
 
97
97
  const WORKER_COLORS = [c.cyan, c.magenta, c.yellow, c.green, c.blue, c.red];
98
+ // Unique marker written to stdout so we can query cursor position via DSR response
99
+ const MARKER = '\x1b[6n\x1b[@@MARKER@@';
98
100
  const DANK_MEMER_ID = '270904126974590976';
99
101
 
100
-
101
- // Simple uptime formatter
102
- function formatUptime() {
103
- const s = Math.floor((Date.now() - startTime) / 1000);
104
- if (s < 60) return `${s}s`;
105
- const m = Math.floor(s / 60);
106
- if (m < 60) return `${m}m ${s % 60}s`;
107
- const h = Math.floor(m / 60);
108
- if (h < 24) return `${h}h ${m % 60}m`;
109
- const d = Math.floor(h / 24);
110
- return `${d}d ${h % 24}h`;
111
- }
112
102
  // ── Safe options for search/crime ──────────────────────────
113
103
  // Object.freeze → V8 marks these as immutable, enabling inline caching
114
104
  // and preventing accidental mutation across 10K worker instances.
@@ -130,8 +120,6 @@ let API_URL = '';
130
120
  let REDIS_URL = process.env.REDIS_URL || '';
131
121
  let redis = null;
132
122
  let workers = [];
133
- let startTime = 0;
134
- let shutdownCalled = false;
135
123
 
136
124
  // ── Cluster Mode Config ──────────────────────────────────────
137
125
  // NODE_ID uniquely identifies this process in a multi-node cluster.
@@ -145,25 +133,10 @@ const CLUSTER_PREFIX = 'dkg:cluster:';
145
133
  function initRedis() {
146
134
  if (!redis && REDIS_URL) {
147
135
  try {
148
- redis = new Redis(REDIS_URL, {
149
- maxRetriesPerRequest: 3,
150
- retryStrategy: (times) => times > 5 ? null : Math.min(times * 500, 3000),
151
- lazyConnect: true,
152
- });
153
- redis.connect().catch((e) => {
154
- // Only warn once — don't spam on persistent connection failures
155
- if (!redis || redis.status === 'wait') {
156
- console.warn(`[Redis] connection failed: ${e.message} — continuing without Redis`);
157
- }
158
- });
159
- redis.on('error', (e) => {
160
- // Suppress common transient errors from spamming stderr
161
- const msg = e?.message || '';
162
- if (msg.includes('ETIMEDOUT') || msg.includes('ECONNRESET') || msg.includes('ENOTFOUND') || msg.includes('connect')) return;
163
- console.error(`[Redis] error: ${msg}`);
164
- });
136
+ redis = new Redis(REDIS_URL, { maxRetriesPerRequest: null, lazyConnect: true });
137
+ redis.connect().catch(() => {});
165
138
  } catch (e) {
166
- // Redis optional continue without it
139
+ console.error('Redis connection failed', e);
167
140
  }
168
141
  }
169
142
  }
@@ -285,6 +258,17 @@ function progressBar(value, max, width, filledColor, emptyColor) {
285
258
  return rgb(fc[0], fc[1], fc[2]) + '█'.repeat(filled) + rgb(ec[0], ec[1], ec[2]) + '░'.repeat(empty) + c.reset;
286
259
  }
287
260
 
261
+ // ── Animated braille spinner frames ──────────────────────────
262
+ const BRAILLE_SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
263
+ const BLOCK_SPIN = ['▉', '▊', '▋', '▌', '▍', '▎', '▏', '▎', '▍', '▌', '▋', '▊'];
264
+ const PULSE_CHARS = ['○', '◎', '●', '◉', '●', '◎'];
265
+ function getSpinner(type = 'braille') {
266
+ const now = Math.floor(Date.now() / 80);
267
+ if (type === 'block') return BLOCK_SPIN[now % BLOCK_SPIN.length];
268
+ if (type === 'pulse') return PULSE_CHARS[now % PULSE_CHARS.length];
269
+ return BRAILLE_SPIN[now % BRAILLE_SPIN.length];
270
+ }
271
+
288
272
  // ── Box drawing helpers ──────────────────────────────────────
289
273
  const BOX = {
290
274
  tl: '╭', tr: '╮', bl: '╰', br: '╯',
@@ -337,28 +321,123 @@ function colorBanner() {
337
321
  return out;
338
322
  }
339
323
 
340
- // ── Simple Logging ─────────────────────────────────────────────
341
- function log(type, msg, label) {
342
- // Route grinding logs through terminal flash events when active
343
- if (terminal._active) {
344
- const clean = stripAnsi(String(msg || '')).substring(0, 120);
345
- const tagClean = stripAnsi(String(label || ''));
346
- terminal.flashEvent(
347
- type === 'error' ? 'death' : type === 'warn' ? 'warn' : 'info',
348
- `${tagClean ? tagClean + ' ' : ''}${clean}`
349
- );
350
- return;
324
+ // ── Live Dashboard State ─────────────────────────────────────
325
+ let dashboardLines = 0;
326
+ let dashboardStarted = false;
327
+ let dashboardRendering = false;
328
+ let lastRenderTime = 0;
329
+ let renderPending = false;
330
+ let totalBalance = 0;
331
+ let totalCoins = 0;
332
+ let totalCommands = 0;
333
+ let startTime = Date.now();
334
+ let shutdownCalled = false;
335
+ let sessionPeakCoins = 0;
336
+ let isNewHigh = false;
337
+ // RingBuffer: O(1) push, bounded memory, no array shifting or GC pressure
338
+ const recentLogs = new RingBuffer(8);
339
+ const MAX_LOGS = 8;
340
+ const RENDER_THROTTLE_MS = 200;
341
+ // Earnings history for sparkline (sample every 10 seconds)
342
+ const earningsHistory = new RingBuffer(30);
343
+ let lastEarningsSample = 0;
344
+ // Per-command stats tracking
345
+ const cmdStats = new Map();
346
+ // Coins per minute history for rate graph
347
+ const cpmHistory = new RingBuffer(20);
348
+ let lastCpmSample = 0;
349
+
350
+ function formatUptime() {
351
+ const s = Math.floor((Date.now() - startTime) / 1000);
352
+ const h = Math.floor(s / 3600);
353
+ const m = Math.floor((s % 3600) / 60);
354
+ const sec = s % 60;
355
+ if (h > 0) return `${h}h ${m}m ${sec}s`;
356
+ if (m > 0) return `${m}m ${sec}s`;
357
+ return `${sec}s`;
358
+ }
359
+
360
+ function formatCoins(n) {
361
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
362
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
363
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
364
+ return n.toLocaleString();
365
+ }
366
+
367
+ function scheduleRender() {
368
+ if (renderPending || !dashboardStarted) return;
369
+ const now = Date.now();
370
+ const elapsed = now - lastRenderTime;
371
+ if (elapsed >= RENDER_THROTTLE_MS) {
372
+ renderDashboard();
373
+ } else {
374
+ renderPending = true;
375
+ setTimeout(() => { renderPending = false; renderDashboard(); }, RENDER_THROTTLE_MS - elapsed);
351
376
  }
377
+ }
378
+
379
+ // ── Dashboard ──────────────────────────────────────────────────────────────────
380
+ // Thin wrapper: aggregates stats then delegates to ./dashboard.js
381
+ function renderDashboard() {
382
+ if (!dashboardStarted || workers.length === 0 || dashboardRendering || shutdownCalled) return;
383
+ dashboardRendering = true;
384
+ lastRenderTime = Date.now();
385
+
386
+ // Aggregate session totals
387
+ totalBalance = 0; totalCoins = 0; totalCommands = 0;
388
+ let totalErrors = 0;
389
+ for (const w of workers) {
390
+ totalBalance += (w.stats.balance || 0) + (w.stats.bankBalance || 0);
391
+ totalCoins += w.stats.coins || 0;
392
+ totalCommands += w.stats.commands || 0;
393
+ totalErrors += w.stats.errors || 0;
394
+ }
395
+ if (totalCoins > sessionPeakCoins) {
396
+ sessionPeakCoins = totalCoins;
397
+ isNewHigh = true;
398
+ setTimeout(() => { isNewHigh = false; }, 3000);
399
+ }
400
+
401
+ // Pass all state into the dashboard module
402
+ const newLines = renderDashboardImpl({
403
+ workers, dashboardStarted, dashboardRendering, dashboardLines,
404
+ totalBalance, totalCoins, totalCommands, startTime,
405
+ sessionPeakCoins, isNewHigh, recentLogs, globalCmdRate,
406
+ earningsHistory, lastEarningsSample,
407
+ CLOUD_MODE, CLUSTER_ENABLED, PKG_VERSION,
408
+ AccountWorker, PULSE_CHARS, getSpinner, gradientText,
409
+ rgb, c, BOX,
410
+ });
411
+
412
+ if (newLines != null) dashboardLines = Math.max(dashboardLines, newLines);
413
+ dashboardRendering = false;
414
+ }
352
415
 
353
- const colorIcons = {
354
- info: `${c.dim}·${c.reset}`, success: `${rgb(52, 211, 153)}✓${c.reset}`,
355
- error: `${rgb(239, 68, 68)}✗${c.reset}`, warn: `${rgb(251, 191, 36)}!${c.reset}`,
356
- cmd: `${rgb(168, 85, 247)}▸${c.reset}`, coin: `${rgb(251, 191, 36)}$${c.reset}`,
357
- buy: `${rgb(59, 130, 246)}♦${c.reset}`, bal: `${rgb(52, 211, 153)}◈${c.reset}`,
358
- debug: `${c.dim}·${c.reset}`,
416
+ function log(type, msg, label) {
417
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
418
+ const icons = {
419
+ info: '·', success: '✓', error: '✗', warn: '!',
420
+ cmd: '▸', coin: '$', buy: '♦', bal: '◈', debug: '·',
359
421
  };
360
- const tagCol = label ? `${label} ` : '';
361
- console.log(` ${colorIcons[type] || colorIcons.info} ${tagCol}${msg}`);
422
+ const tagRaw = label ? label.replace(/\x1b\[[0-9;]*m/g, '').substring(0, 12) : '';
423
+ const stripped = msg.replace(/\x1b\[[0-9;]*m/g, '');
424
+ const tw = Math.max(process.stdout.columns || 80, 60);
425
+ if (dashboardStarted) {
426
+ const maxLen = tw - 8;
427
+ const entry = `${time} ${icons[type] || '·'} ${tagRaw ? tagRaw + ' ' : ''}${stripped}`;
428
+ recentLogs.push(entry.substring(0, maxLen));
429
+ scheduleRender();
430
+ } else {
431
+ const colorIcons = {
432
+ info: `${c.dim}·${c.reset}`, success: `${rgb(52, 211, 153)}✓${c.reset}`,
433
+ error: `${rgb(239, 68, 68)}✗${c.reset}`, warn: `${rgb(251, 191, 36)}!${c.reset}`,
434
+ cmd: `${rgb(168, 85, 247)}▸${c.reset}`, coin: `${rgb(251, 191, 36)}$${c.reset}`,
435
+ buy: `${rgb(59, 130, 246)}♦${c.reset}`, bal: `${rgb(52, 211, 153)}◈${c.reset}`,
436
+ debug: `${c.dim}·${c.reset}`,
437
+ };
438
+ const tagCol = label ? `${label} ` : '';
439
+ console.log(` ${colorIcons[type] || colorIcons.info} ${tagCol}${msg}`);
440
+ }
362
441
  }
363
442
 
364
443
  async function fetchConfig(retries = 3, delayMs = 1500, opts = {}) {
@@ -790,6 +869,7 @@ class AccountWorker {
790
869
 
791
870
  setStatus(text) {
792
871
  this.lastStatus = stripAnsi(String(text || '')).replace(/\s+/g, ' ').trim();
872
+ if (dashboardStarted) scheduleRender();
793
873
  }
794
874
 
795
875
  waitForDankMemer(timeout = 15000) {
@@ -1402,32 +1482,34 @@ class AccountWorker {
1402
1482
 
1403
1483
  // Update Redis with findings
1404
1484
  if (redis) {
1405
- try {
1406
- if (currentLevel > 0) {
1407
- await redis.set(`dkg:level:${this.account.id}`, String(currentLevel), 'EX', 2592000);
1408
- this._level = currentLevel;
1409
- }
1410
- if (lastLifesaverCount >= 0) {
1411
- await redis.set(`dkg:lifesavers:${this.account.id}`, String(lastLifesaverCount), 'EX', 86400);
1412
- this._lifesavers = lastLifesaverCount;
1413
- if (lastLifesaverCount === 0) {
1414
- await redis.set(`raw:alert:no-lifesaver:${dm.id}`, '1', 'EX', 86400);
1415
- await redis.set(`raw:alert:no-lifesaver:${this.channel?.id}`, '1', 'EX', 86400);
1416
- }
1485
+ if (currentLevel > 0) {
1486
+ await redis.set(`dkg:level:${this.account.id}`, String(currentLevel), 'EX', 2592000);
1487
+ this._level = currentLevel;
1488
+ // Only log to terminal during startup — after dashboardStarted, go to live feed
1489
+ if (dashboardStarted) this.log('info', `DM level: ${c.bold}${currentLevel}${c.reset}`);
1490
+ }
1491
+ if (lastLifesaverCount >= 0) {
1492
+ await redis.set(`dkg:lifesavers:${this.account.id}`, String(lastLifesaverCount), 'EX', 86400);
1493
+ this._lifesavers = lastLifesaverCount;
1494
+ if (lastLifesaverCount === 0) {
1495
+ await redis.set(`raw:alert:no-lifesaver:${dm.id}`, '1', 'EX', 86400);
1496
+ await redis.set(`raw:alert:no-lifesaver:${this.channel?.id}`, '1', 'EX', 86400);
1497
+ if (dashboardStarted) this.log('error', `${c.red}0 LIFESAVERS! Crime/Search will be disabled.${c.reset}`);
1417
1498
  }
1418
- } catch { /* Redis errors non-fatal */ }
1499
+ }
1419
1500
  }
1420
1501
 
1421
- return { deaths, levelUps, currentLevel, lifesavers: lastLifesaverCount, dmChannelId: dm.id };
1422
- } catch (e) {
1423
- lastError = e;
1424
- if (attempt < maxRetries - 1) {
1425
- await new Promise(r => setTimeout(r, delays[attempt]));
1502
+ return { deaths, levelUps, currentLevel, lifesavers: lastLifesaverCount, dmChannelId: dm.id };
1503
+ } catch (e) {
1504
+ lastError = e;
1505
+ if (attempt < maxRetries - 1) {
1506
+ await new Promise(r => setTimeout(r, delays[attempt]));
1507
+ }
1426
1508
  }
1427
1509
  }
1510
+ if (dashboardStarted) this.log('debug', `DM check failed after ${maxRetries} attempts: ${lastError.message}`);
1511
+ return { deaths: 0, levelUps: 0, currentLevel: 0, lifesavers: -1 };
1428
1512
  }
1429
- return { deaths: 0, levelUps: 0, currentLevel: 0, lifesavers: -1 };
1430
- }
1431
1513
 
1432
1514
  // ── Run Single Command ──────────────────────────────────────
1433
1515
  // Each modular command handler sends the command, waits for response,
@@ -1635,8 +1717,6 @@ class AccountWorker {
1635
1717
  // ── Death / lifesaver detection in command responses ──
1636
1718
  if (resultLower.includes('you died') || resultLower.includes('lifesaver protected')) {
1637
1719
  this.log('error', `DEATH DETECTED during ${cmdName}!`);
1638
- terminal.flashEvent('death', `💀 ${this.username} died — ${lsCount === 0 ? '0 lifesavers!' : (lsCount > 0 ? `${lsCount} lifesavers left` : 'check DM')}`);
1639
- terminal.markWorkerDirty(this.idx);
1640
1720
  // Check for lifesaver count in the response
1641
1721
  const lsMatch = result.match(/(\d+)\s*life\s*saver/i);
1642
1722
  const lsCount = lsMatch ? parseInt(lsMatch[1]) : -1;
@@ -1653,8 +1733,6 @@ class AccountWorker {
1653
1733
  await redis.set(`dkg:lifesavers:${this.account.id}`, String(lsCount), 'EX', 86400);
1654
1734
  this.log('warn', `Lifesaver used! ${lsCount} remaining.`);
1655
1735
  if (lsCount <= 2) {
1656
- terminal.flashEvent('lowls', `⚠ ${this.username} low lifesavers: ${lsCount} left`);
1657
- terminal.markWorkerDirty(this.idx);
1658
1736
  sendWebhook('LOW LIFESAVERS', `**${this.username}** has only **${lsCount}** lifesaver(s) left!`, 0xfbbf24);
1659
1737
  }
1660
1738
  }
@@ -1774,8 +1852,6 @@ class AccountWorker {
1774
1852
  this.setStatus(formattedResult);
1775
1853
  await sendLog(this.username, cmdName, result, 'success');
1776
1854
  reportEarnings(this.account.id, this.username, earned, spent, cmdName);
1777
- terminal.markWorkerDirty(this.idx);
1778
- if (earned > 0) terminal.flashEvent('success', `⚔ ${this.username} ${cmdName}: +⏣ ${earned.toLocaleString()}`);
1779
1855
 
1780
1856
  // Auto-sell fish every 5 fishing rounds
1781
1857
  if (cmdName === 'fish') {
@@ -2759,23 +2835,34 @@ async function start(apiKey, apiUrl, opts = {}) {
2759
2835
  API_KEY = apiKey;
2760
2836
  API_URL = apiUrl || process.env.DANKGRINDER_URL || 'http://localhost:3000';
2761
2837
  const CLOUD_MODE = opts.cloud === true;
2762
- startTime = Date.now();
2763
2838
 
2839
+ if (CLOUD_MODE) {
2840
+ // In cloud mode, API_KEY is the CLOUD_ADMIN_KEY — not used for user auth.
2841
+ // Per-account keys are fetched per-account from /api/cloud/grinders.
2842
+ console.log('🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts');
2843
+ }
2764
2844
  REDIS_URL = process.env.REDIS_URL || '';
2765
2845
  WEBHOOK_URL = process.env.WEBHOOK_URL || '';
2766
2846
 
2847
+ process.stdout.write('\x1b[2J\x1b[H');
2848
+ const tw = Math.min(process.stdout.columns || 80, 78);
2849
+ const bar = c.dim + '─'.repeat(tw) + c.reset;
2850
+
2767
2851
  // Detect zlib-sync availability
2768
2852
  let hasZlib = false;
2769
2853
  try { require('zlib-sync'); hasZlib = true; } catch {}
2770
2854
 
2771
- // Init terminal FIRST — captures all subsequent console.log output
2772
- terminal.setVersion(PKG_VERSION);
2773
- terminal.init({ workers: [], startTime });
2855
+ console.log(colorBanner());
2856
+ console.log(
2857
+ ` ${rgb(139, 92, 246)}v${PKG_VERSION}${c.reset}` +
2858
+ ` ${c.dim}·${c.reset} ${c.white}${AccountWorker.COMMAND_MAP.length} Commands${c.reset}` +
2859
+ ` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLOUD_MODE ? 'Cloud Mode' : (CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone')}${c.reset}` +
2860
+ ` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}Auto-Recovery${c.reset}` +
2861
+ ` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}Loss Limiter${c.reset}`
2862
+ );
2863
+ console.log(bar);
2774
2864
 
2775
- if (CLOUD_MODE) {
2776
- console.log(`${rgb(139, 92, 246)}🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts${c.reset}`);
2777
- }
2778
- terminal.startPhase('Fetching accounts...');
2865
+ log('info', `${c.dim}Fetching accounts...${c.reset}`);
2779
2866
 
2780
2867
  const fetchOpts = CLOUD_MODE ? { cloud: true } : {};
2781
2868
  let data = await fetchConfig(4, 2000, fetchOpts);
@@ -2786,11 +2873,9 @@ async function start(apiKey, apiUrl, opts = {}) {
2786
2873
  data = await fetchConfig(4, 2000, fetchOpts);
2787
2874
  }
2788
2875
  if (data && data.error) {
2789
- terminal.endPhase(`API error: ${data.error}`, false);
2790
2876
  log('error', `${data.error}`);
2791
2877
  return;
2792
2878
  }
2793
- terminal.endPhase(`API connected — ${data.accounts?.length || 0} accounts`);
2794
2879
 
2795
2880
  // Cloud mode: post heartbeat every 30s
2796
2881
  if (CLOUD_MODE) {
@@ -2835,9 +2920,13 @@ async function start(apiKey, apiUrl, opts = {}) {
2835
2920
  }
2836
2921
  }
2837
2922
 
2923
+ const checks = [];
2924
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}API${c.reset}`);
2925
+ if (REDIS_URL) checks.push(redis ? `${rgb(52, 211, 153)}✓${c.reset} ${c.white}Redis${c.reset}` : `${rgb(251, 191, 36)}○${c.reset} ${c.dim}Redis (connecting...)${c.reset}`);
2926
+
2838
2927
  // Init rawLogger Redis (uses same URL — logs all raw gateway data)
2839
2928
  if (REDIS_URL) {
2840
- rawLogger.init(redis);
2929
+ rawLogger.init(REDIS_URL).catch(() => {});
2841
2930
  // Listen for DM events across all accounts — update worker state + dashboard LIVE
2842
2931
  rawLogger.onDmEvent((event, raw) => {
2843
2932
  const channelId = raw.channel_id;
@@ -2854,8 +2943,6 @@ async function start(apiKey, apiUrl, opts = {}) {
2854
2943
  w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
2855
2944
  w.setCooldown?.('crime', 86400);
2856
2945
  w.setCooldown?.('search', 86400);
2857
- terminal.flashEvent('death', `💀 ${w.username} DEATH in DM — 0 lifesavers!`);
2858
- terminal.markWorkerDirty(w.idx);
2859
2946
  sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
2860
2947
  } else {
2861
2948
  w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
@@ -2868,25 +2955,132 @@ async function start(apiKey, apiUrl, opts = {}) {
2868
2955
  }
2869
2956
  }
2870
2957
  }
2958
+ scheduleRender();
2871
2959
  }
2872
2960
 
2873
2961
  if (event.type === 'levelup') {
2874
2962
  if (event.to > 0) {
2875
2963
  w._level = event.to;
2876
- terminal.flashEvent('levelup', `⬆️ ${w.username} leveled up to Lv.${event.to}`);
2877
- terminal.markWorkerDirty(w.idx);
2964
+ scheduleRender();
2878
2965
  }
2879
2966
  }
2880
2967
  }
2881
2968
  });
2969
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
2882
2970
  }
2971
+ if (hasZlib) checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}zlib${c.reset}`);
2972
+ if (WEBHOOK_URL) checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}Webhook${c.reset}`);
2973
+ if (CLUSTER_ENABLED) {
2974
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${rgb(34, 211, 238)}Cluster${c.reset} ${c.dim}(${NODE_ID.substring(0, 12)})${c.reset}`);
2975
+ }
2976
+ checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}${accounts.length} Account${accounts.length > 1 ? 's' : ''}${c.reset}`);
2977
+ console.log(` ${checks.join(' ')}`);
2978
+ console.log('');
2979
+
2980
+ // ── Phase 1: Login with per-account inline rendering ─────────────────────────
2981
+ const startupTw = process.stdout.columns || 90;
2982
+ const colNum = 4; // " #"
2983
+ const colSts = 3; // "ST"
2984
+ const colName = Math.min(24, Math.max(12, Math.floor(startupTw * 0.25)));
2985
+ const colGuild = Math.min(18, Math.max(8, Math.floor(startupTw * 0.2)));
2986
+ const colCmds = 8;
2987
+ const loginVis = colNum + colSts + colName + colGuild + colCmds + 10;
2988
+
2989
+ const loginStates = accounts.map((acc, i) => ({
2990
+ name: acc.label || acc.id || '?',
2991
+ done: false,
2992
+ failed: false,
2993
+ worker: null,
2994
+ }));
2883
2995
 
2884
- // ── Terminal renderer init ─────────────────────────────────────
2885
- terminal.setVersion(PKG_VERSION);
2886
- terminal.init({ workers, startTime });
2887
- terminal.startPhase(`Connecting ${accounts.length} accounts to Discord`);
2996
+ let loginLines = [];
2997
+ loginLines.push(` ${'─'.repeat(loginVis)}`);
2998
+ for (let i = 0; i < loginStates.length; i++) {
2999
+ const s = loginStates[i];
3000
+ const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset}`;
3001
+ const name = s.name.substring(0, colName).padEnd(colName);
3002
+ const guild = c.dim + '···'.padEnd(colGuild) + c.reset;
3003
+ const cmds = c.dim + '···'.padEnd(colCmds) + c.reset;
3004
+ loginLines.push(` ${num} ${c.dim}··${c.reset} ${name} ${guild} ${cmds}`);
3005
+ }
3006
+ loginLines.push(` ${'─'.repeat(loginVis)}`);
3007
+ for (const l of loginLines) console.log(l);
3008
+
3009
+ // Dynamically capture the starting row of the login table via DSR.
3010
+ // Write MARKER to stderr (not stdout) to avoid PTY cooked-mode echoing
3011
+ // of the visible "@MARKER@@" text portion, which was causing the DSR
3012
+ // response to be swallowed or delayed.
3013
+ let loginBaseRow = 1;
3014
+ const captureLoginRow = () => new Promise(resolve => {
3015
+ const chunks = [];
3016
+ const handler = (chunk) => {
3017
+ chunks.push(chunk);
3018
+ const raw = chunks.join('');
3019
+ const m = raw.match(/\x1b\[(\d+);\d+R/);
3020
+ if (m) {
3021
+ process.stdin.removeListener('data', handler);
3022
+ loginBaseRow = parseInt(m[1], 10) + 1;
3023
+ resolve();
3024
+ }
3025
+ };
3026
+ process.stdin.on('data', handler);
3027
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3028
+ process.stderr.write(MARKER);
3029
+ setTimeout(resolve, 50);
3030
+ });
3031
+ await captureLoginRow();
3032
+
3033
+ let loginPending = new Array(accounts.length).fill(true);
3034
+ const moveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3035
+
3036
+ const drawLoginSpinners = () => {
3037
+ for (let i = 0; i < loginPending.length; i++) {
3038
+ if (!loginPending[i]) continue;
3039
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3040
+ const num = `${c.dim}${(i + 1).toString().padStart(colNum - 1)}${c.reset}`;
3041
+ const name = loginStates[i].name.substring(0, colName).padEnd(colName);
3042
+ const guild = c.dim + 'logging in...'.substring(0, colGuild) + c.reset;
3043
+ const cmds = c.dim + '···'.padEnd(colCmds) + c.reset;
3044
+ const row = loginBaseRow + 1 + i; // +1 skips the top border line
3045
+ moveToRow(row);
3046
+ process.stdout.write(` ${num} ${rgb(139, 92, 246)}${spin}${c.reset} ${name} ${guild} ${cmds}\x1b[K`);
3047
+ }
3048
+ // Move cursor back to bottom to avoid overwriting the bottom border
3049
+ const lastRow = loginBaseRow + 1 + accounts.length + 1;
3050
+ moveToRow(lastRow);
3051
+ };
3052
+ const loginSpinnerInterval = setInterval(drawLoginSpinners, 80);
3053
+
3054
+ const finalizeLoginLine = (idx, worker) => {
3055
+ if (!loginPending[idx]) return;
3056
+ loginPending[idx] = false;
3057
+ const s = loginStates[idx];
3058
+ s.done = true;
3059
+ s.worker = worker;
3060
+
3061
+ const num = `${c.dim}${(idx + 1).toString().padStart(colNum - 1)}${c.reset}`;
3062
+ const name = (worker.username || s.name || '?').substring(0, colName).padEnd(colName);
3063
+ let sts, guild, cmds;
3064
+ if (worker._tokenInvalid) {
3065
+ sts = `${rgb(239, 68, 68)}✗${c.reset}`;
3066
+ guild = 'INVALID'.padEnd(colGuild);
3067
+ cmds = '···'.padEnd(colCmds);
3068
+ s.failed = true;
3069
+ } else if (worker.channel) {
3070
+ sts = `${rgb(52, 211, 153)}✓${c.reset}`;
3071
+ const gn = (worker.channel.guild?.name || worker.channel.guild?.id || 'DM').substring(0, colGuild);
3072
+ guild = gn.padEnd(colGuild);
3073
+ cmds = `${worker.stats?.commands || 0}`.padEnd(colCmds);
3074
+ } else {
3075
+ sts = `${rgb(251, 146, 60)}⏳${c.reset}`;
3076
+ guild = 'timeout'.padEnd(colGuild);
3077
+ cmds = '···'.padEnd(colCmds);
3078
+ }
3079
+ const row = loginBaseRow + 1 + idx; // +1 skips the top border line
3080
+ moveToRow(row);
3081
+ process.stdout.write(` ${num} ${sts} ${name} ${c.dim}${guild}${c.reset} ${c.dim}${cmds}${c.reset}\x1b[K`);
3082
+ };
2888
3083
 
2889
- // ── Phase 1: Login accounts ─────────────────────────────────────────
2890
3084
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
2891
3085
  const parsedGapMax = Number.parseInt(String(process.env.LOGIN_GAP_MAX_MS || '150'), 10);
2892
3086
  const LOGIN_GAP_MIN_MS = Number.isFinite(parsedGapMin) && parsedGapMin >= 0 ? parsedGapMin : 50;
@@ -2902,70 +3096,203 @@ async function start(apiKey, apiUrl, opts = {}) {
2902
3096
  const worker = new AccountWorker(acc, i + idx);
2903
3097
  workers.push(worker);
2904
3098
  workerMap.set(acc.id, worker);
3099
+ loginStates[i + idx].worker = worker;
2905
3100
  await worker.start();
2906
- terminal.updateProgress(workers.length, accounts.length);
3101
+ finalizeLoginLine(i + idx, worker);
2907
3102
  }));
2908
3103
  if (i + BATCH_SIZE < accounts.length) await new Promise(r => setTimeout(r, randomLoginGap()));
2909
3104
  hintGC();
2910
3105
  }
2911
3106
 
3107
+ clearInterval(loginSpinnerInterval);
2912
3108
  const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
2913
3109
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
2914
3110
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
2915
- terminal.endPhase(`${loginDone}/${accounts.length} accounts connected`, true);
3111
+ console.log(`\r\x1b[2K ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Login complete${c.reset} ${rgb(52, 211, 153)}${loginDone}${c.reset}${c.dim}/${c.reset}${c.white}${accounts.length}${c.reset} ${c.dim}accounts connected${c.reset}`);
3112
+ console.log('');
2916
3113
  if (invalidWorkers.length > 0) {
2917
- terminal.flashEvent('error', `${invalidWorkers.length} accounts have INVALID tokens`);
2918
- log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens`);
2919
- for (const w of invalidWorkers) log('error', ` ${w.account.label || w.account.id} — token invalid or expired`);
3114
+ log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens:${c.reset}`);
3115
+ for (const w of invalidWorkers) log('error', ` ✗ ${w.account.label || w.account.id} token is invalid or expired`);
3116
+ console.log('');
2920
3117
  }
2921
- if (timedOutWorkers.length > 0) log('warn', `${timedOutWorkers.length} account(s) timed out during login`);
3118
+ if (timedOutWorkers.length > 0) log('warn', `${timedOutWorkers.length} account(s) timed out during login (will retry in background)`);
2922
3119
 
2923
3120
  const activeWorkers = workers.filter(w => !w._tokenInvalid);
2924
3121
 
2925
- // ── Phase 2: Inventory check ─────────────────────────────────────
2926
- terminal.startPhase('Checking inventory');
2927
- terminal.updateProgress(0, activeWorkers.length);
2928
- let invDone = 0, invFailed = 0;
2929
- await Promise.all(activeWorkers.map(async (w) => {
2930
- try { await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
2931
- catch { invFailed++; return; }
2932
- invDone++;
2933
- terminal.updateProgress(invDone, activeWorkers.length);
3122
+ // ── Phase 2: Inventory check — spinner for pending count, results inline ─────────
3123
+ const iColNum = 4;
3124
+ const iColName = Math.min(22, Math.max(12, Math.floor(startupTw * 0.22)));
3125
+ const iColItems = 8;
3126
+ const iColVal = 16;
3127
+ const invVis = 7 + iColNum + iColName + iColItems + iColVal + 12;
3128
+
3129
+ // Print a unique marker, query its position, then overwrite it with the table
3130
+ // Set up stdin handler BEFORE writing MARKER (same fix as Phase 1 — avoids race)
3131
+ let invBaseRow = 1;
3132
+ const captureRow = () => new Promise(resolve => {
3133
+ const chunks = [];
3134
+ const handler = (chunk) => {
3135
+ chunks.push(chunk);
3136
+ const raw = chunks.join('');
3137
+ const m = raw.match(/\x1b\[(\d+);\d+R/);
3138
+ if (m) {
3139
+ process.stdin.removeListener('data', handler);
3140
+ invBaseRow = parseInt(m[1], 10) + 1; // +1: first account row is after marker
3141
+ resolve();
3142
+ }
3143
+ };
3144
+ process.stdin.on('data', handler);
3145
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3146
+ process.stderr.write(MARKER);
3147
+ setTimeout(resolve, 50);
3148
+ });
3149
+ await captureRow();
3150
+
3151
+ // Now print the inventory table starting at invBaseRow
3152
+ const invMoveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3153
+ console.log(` ${'─'.repeat(invVis)}`);
3154
+ for (let i = 0; i < activeWorkers.length; i++) {
3155
+ const w = activeWorkers[i];
3156
+ const num = `${c.dim}${(i + 1).toString().padStart(iColNum - 1)}${c.reset}`;
3157
+ const name = (w.username || w.account.label || '?').substring(0, iColName).padEnd(iColName);
3158
+ console.log(` ${num} ${c.dim}··${c.reset} ${name} ${c.dim}${'checking...'.padEnd(iColItems)}${c.reset} ${c.dim}${'···'.padEnd(iColVal)}${c.reset}`);
3159
+ }
3160
+ console.log(` ${'─'.repeat(invVis)}`);
3161
+
3162
+ let invDone = 0, invFailed = 0, invPending = activeWorkers.length;
3163
+ const drawInvProgress = () => {
3164
+ if (invPending === 0) return;
3165
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3166
+ const pct = activeWorkers.length > 0 ? ((activeWorkers.length - invPending) / activeWorkers.length) : 0;
3167
+ const barW = Math.min(20, startupTw - 40);
3168
+ const filled = Math.round(pct * barW);
3169
+ const bar = rgb(34, 211, 238) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(barW - filled) + c.reset;
3170
+ const pctStr = `${Math.round(pct * 100)}%`;
3171
+ invMoveToRow(invBaseRow);
3172
+ process.stdout.write(` ${rgb(34, 211, 238)}${spin}${c.reset} ${c.dim}Inventory...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${activeWorkers.length - invPending}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} ${c.dim}${pctStr}${c.reset} \x1b[K`);
3173
+ };
3174
+ const invSpinnerInterval = setInterval(drawInvProgress, 80);
3175
+
3176
+ await Promise.all(activeWorkers.map(async (w, i) => {
3177
+ const num = `${c.dim}${(i + 1).toString().padStart(iColNum - 1)}${c.reset}`;
3178
+ const name = (w.username || w.account.label || '?').substring(0, iColName).padEnd(iColName);
3179
+ let invRes;
3180
+ try { invRes = await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
3181
+ catch { invRes = { ok: false }; }
3182
+ invPending--;
3183
+ const items = invRes?.ok ? (invRes.result?.items?.length || 0) : 0;
3184
+ const val = invRes?.ok ? (invRes.result?.totalValue || 0) : 0;
3185
+ const sts = invRes?.ok ? `${rgb(52, 211, 153)}✓${c.reset}` : `${rgb(239, 68, 68)}✗${c.reset}`;
3186
+ const itemStr = `${items}`.padEnd(iColItems);
3187
+ const valStr = invRes?.ok ? `${c.green}⏣${val.toLocaleString()}${c.reset}` : `${c.dim}···${c.reset}`;
3188
+ const row = invBaseRow + 1 + i;
3189
+ invMoveToRow(row);
3190
+ process.stdout.write(` ${num} ${sts} ${name} ${itemStr} ${valStr.padEnd(iColVal + 5)}\x1b[K`);
3191
+ if (invRes?.ok) invDone++; else invFailed++;
2934
3192
  }));
2935
3193
 
3194
+ clearInterval(invSpinnerInterval);
3195
+ process.stdout.write(`\r\x1b[2K`);
3196
+
2936
3197
  if (invFailed > 0) {
2937
- terminal.endPhase(`Inventory: ${invFailed} failed`, false);
2938
- console.log(` ${rgb(239, 68, 68)}✗${c.reset} Inventory: ${invFailed} failed — not starting grind loops`);
3198
+ console.log(` ${rgb(239, 68, 68)}✗${c.reset} ${c.bold}Inventory incomplete${c.reset} ${rgb(52, 211, 153)}${invDone}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} ${c.dim}done, ${rgb(239, 68, 68)}${invFailed} failed${c.reset}`);
3199
+ log('error', `${c.red}Not starting grind loops — ${invFailed} accounts failed inventory.${c.reset}`);
2939
3200
  return;
2940
3201
  }
2941
- terminal.endPhase(`Inventory: ${invDone} clear`);
3202
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Inventory complete${c.reset} ${rgb(52, 211, 153)}${invDone}/${activeWorkers.length}${c.reset} ${c.dim}all clear${c.reset}`);
3203
+ console.log('');
3204
+
3205
+ // ── Phase 2.5: Balance check — inline table, single spinner for progress ─────────
3206
+ const bColNum = 4;
3207
+ const bColName = Math.min(22, Math.max(12, Math.floor(startupTw * 0.22)));
3208
+ const bColWallet = 12;
3209
+ const bColBank = 12;
3210
+ const bColTotal = 14;
3211
+ const bColLs = 4;
3212
+ const balVis = 7 + bColNum + bColName + bColWallet + bColBank + bColTotal + bColLs + 14;
3213
+
3214
+ // Capture starting row for balance phase
3215
+ // Set up stdin handler BEFORE writing MARKER (same fix — avoids race + PTY echo)
3216
+ let balBaseRow = 1;
3217
+ const balCaptureRow = () => new Promise(resolve => {
3218
+ const chunks = [];
3219
+ const handler = (chunk) => {
3220
+ chunks.push(chunk);
3221
+ const raw = chunks.join('');
3222
+ const m = raw.match(/\x1b\[(\d+);\d+R/);
3223
+ if (m) {
3224
+ process.stdin.removeListener('data', handler);
3225
+ balBaseRow = parseInt(m[1], 10) + 1;
3226
+ resolve();
3227
+ }
3228
+ };
3229
+ process.stdin.on('data', handler);
3230
+ // Write to stderr so PTY doesn't echo the visible MARKER text to stdout
3231
+ process.stderr.write(MARKER);
3232
+ setTimeout(resolve, 50);
3233
+ });
3234
+ await balCaptureRow();
3235
+
3236
+ const balMoveToRow = (row) => process.stdout.write(`\x1b[${row};1H`);
3237
+ console.log(` ${'─'.repeat(balVis)}`);
3238
+ for (let i = 0; i < activeWorkers.length; i++) {
3239
+ const w = activeWorkers[i];
3240
+ const num = `${c.dim}${(i + 1).toString().padStart(bColNum - 1)}${c.reset}`;
3241
+ const name = (w.username || w.account.label || '?').substring(0, bColName).padEnd(bColName);
3242
+ console.log(` ${num} ${c.dim}··${c.reset} ${name} ${c.dim}${'checking'.padEnd(bColWallet)}${c.reset} ${c.dim}${'···'.padEnd(bColBank)}${c.reset} ${c.dim}${'···'.padEnd(bColTotal)}${c.reset} ${c.dim}♥?${c.reset}`);
3243
+ }
3244
+ console.log(` ${'─'.repeat(balVis)}`);
3245
+
3246
+ let balDone = 0, balPending = activeWorkers.length;
3247
+ const drawBalProgress = () => {
3248
+ if (balPending === 0) return;
3249
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3250
+ const pct = activeWorkers.length > 0 ? ((activeWorkers.length - balPending) / activeWorkers.length) : 0;
3251
+ const barW = Math.min(20, startupTw - 40);
3252
+ const filled = Math.round(pct * barW);
3253
+ const bar = rgb(251, 191, 36) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(barW - filled) + c.reset;
3254
+ balMoveToRow(balBaseRow);
3255
+ process.stdout.write(` ${rgb(251, 191, 36)}${spin}${c.reset} ${c.dim}Balance...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${activeWorkers.length - balPending}${c.reset}${c.dim}/${c.reset}${c.white}${activeWorkers.length}${c.reset} \x1b[K`);
3256
+ };
3257
+ const balSpinnerInterval = setInterval(drawBalProgress, 80);
2942
3258
 
2943
- // ── Phase 2.5: Balance check ────────────────────────────────────
2944
- terminal.startPhase('Checking balance');
2945
- terminal.updateProgress(0, activeWorkers.length);
2946
- let balDone = 0;
2947
- await Promise.all(activeWorkers.map(async (w) => {
3259
+ await Promise.all(activeWorkers.map(async (w, i) => {
2948
3260
  try { await w.checkBalance(true); } catch {}
3261
+ balPending--;
3262
+ const num = `${c.dim}${(i + 1).toString().padStart(bColNum - 1)}${c.reset}`;
3263
+ const name = (w.username || w.account.label || '?').substring(0, bColName).padEnd(bColName);
3264
+ const wallet = w.stats?.balance || 0;
3265
+ const bank = w.stats?.bankBalance || 0;
3266
+ const ls = w._lifesavers ?? '?';
3267
+ const lsColor = ls === 0 ? rgb(239, 68, 68) : ls <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3268
+ const walletStr = `${c.green}⏣${wallet.toLocaleString()}${c.reset}`;
3269
+ const bankStr = `${c.cyan}⏣${bank.toLocaleString()}${c.reset}`;
3270
+ const totalStr = `${c.bold}⏣${(wallet + bank).toLocaleString()}${c.reset}`;
3271
+ const row = balBaseRow + 1 + i;
3272
+ balMoveToRow(row);
3273
+ process.stdout.write(` ${num} ${rgb(52, 211, 153)}✓${c.reset} ${name} ${walletStr.padEnd(bColWallet + 4)} ${bankStr.padEnd(bColBank + 4)} ${totalStr.padEnd(bColTotal + 3)} ${lsColor}♥${ls}${c.reset}\x1b[K`);
2949
3274
  balDone++;
2950
- terminal.updateProgress(balDone, activeWorkers.length);
2951
3275
  }));
2952
3276
 
3277
+ clearInterval(balSpinnerInterval);
3278
+ process.stdout.write(`\r\x1b[2K`);
3279
+
2953
3280
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
2954
3281
  for (const w of activeWorkers) {
2955
3282
  totalWallet += w.stats?.balance || 0;
2956
3283
  totalBank += w.stats?.bankBalance || 0;
2957
3284
  if (w._lifesavers === 0) noLifesaverAccounts.push(w.username || w.account.label);
2958
3285
  }
2959
- terminal.endPhase(`Balance: ⏣ ${(totalWallet + totalBank).toLocaleString()}`);
3286
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Balance${c.reset} Total: ${c.bold}${c.green}⏣ ${(totalWallet + totalBank).toLocaleString()}${c.reset} ${c.dim}(wallet: ⏣ ${totalWallet.toLocaleString()} + bank: ⏣ ${totalBank.toLocaleString()})${c.reset}`);
2960
3287
  if (noLifesaverAccounts.length > 0) {
2961
- terminal.flashEvent('warn', `${noLifesaverAccounts.length} accounts have 0 LIFESAVERS`);
2962
- console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${noLifesaverAccounts.length} account(s) have 0 LIFESAVERS — crime/search disabled`);
3288
+ console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${c.bold}${c.red}WARNING: ${noLifesaverAccounts.length} account(s) have 0 LIFESAVERS!${c.reset} Crime/Search disabled for: ${noLifesaverAccounts.join(', ')}`);
2963
3289
  }
3290
+ console.log('');
3291
+
2964
3292
 
2965
- // ── Phase 2.75: DM history check ────────────────────────────────
2966
- terminal.startPhase('Checking DM history');
2967
- terminal.updateProgress(0, activeWorkers.length);
2968
- let dmDone = 0;
3293
+ // Phase 2.75: Check DM history for deaths/level-ups (sequential, fast)
3294
+ const dmCheckPulse = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
3295
+ console.log(` ${rgb(139, 92, 246)}${dmCheckPulse}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
2969
3296
  let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
2970
3297
  for (const w of activeWorkers) {
2971
3298
  try {
@@ -2974,15 +3301,28 @@ async function start(apiKey, apiUrl, opts = {}) {
2974
3301
  if (dm.levelUps > 0) dmLevelUps += dm.levelUps;
2975
3302
  if (dm.lifesavers === 0) dmNoLs.push(w.username);
2976
3303
  if (dm.lifesavers === -1) dmUnknown.push(w.username);
3304
+ // Store level and lifesaver for dashboard
2977
3305
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
2978
3306
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
3307
+ const parts = [];
3308
+ if (dm.currentLevel > 0) parts.push(`Lv${dm.currentLevel}`);
3309
+ if (dm.deaths > 0) parts.push(`${rgb(239, 68, 68)}${dm.deaths} deaths${c.reset}`);
3310
+ if (dm.lifesavers >= 0) {
3311
+ const lc = dm.lifesavers === 0 ? rgb(239, 68, 68) : dm.lifesavers <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
3312
+ parts.push(`${lc}♥${dm.lifesavers}${c.reset}`);
3313
+ } else {
3314
+ // Unknown lifesavers — pulse to show pending
3315
+ const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
3316
+ parts.push(`${D}${pulse}♥?${c.reset}`);
3317
+ }
3318
+ if (parts.length > 0) {
3319
+ recentLogs.push({ ts: Date.now(), username: w.username, color: w.color, command: 'dm check', response: parts.join(' '), status: 'ok' });
3320
+ }
2979
3321
  } catch {}
2980
- dmDone++;
2981
- terminal.updateProgress(dmDone, activeWorkers.length);
2982
3322
  }
2983
3323
  if (dmNoLs.length > 0) {
2984
- terminal.flashEvent('warn', `No lifesavers: ${dmNoLs.join(', ')}`);
2985
- log('warn', `⚠ No lifesavers: ${dmNoLs.join(', ')}`);
3324
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(239, 68, 68), command: 'dm check', response: `⚠ No lifesavers: ${dmNoLs.join(', ')}`, status: 'warn' });
3325
+ // Set Redis keys to block crime/search
2986
3326
  for (const w of activeWorkers) {
2987
3327
  if (dmNoLs.includes(w.username) && redis) {
2988
3328
  try {
@@ -2993,22 +3333,45 @@ async function start(apiKey, apiUrl, opts = {}) {
2993
3333
  }
2994
3334
  }
2995
3335
  if (dmUnknown.length > 0) {
2996
- log('warn', `⚠ Lifesavers unknown — live monitor: ${dmUnknown.join(', ')}`);
3336
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(251, 191, 36), command: 'dm check', response: `⚠ Lifesavers unknown — live monitor: ${dmUnknown.join(', ')}`, status: 'warn' });
3337
+ // Crime/search on these accounts will be skipped via safety hold until the live
3338
+ // DM gateway listener detects a death (→ sets count) or confirms clean.
2997
3339
  }
2998
3340
  const dmSummaryParts = [];
2999
3341
  if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3000
3342
  if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3001
3343
  if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3002
- terminal.endPhase(`DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
3344
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(52, 211, 153), command: 'dm check', response: dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean — no deaths or level-ups', status: 'ok' });
3345
+ console.log('');
3346
+
3347
+ console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);
3348
+
3349
+ // DEBUG: activeWorkers confirmed
3003
3350
 
3004
- // ── Phase 3: Start grind loops ───────────────────────────────────
3005
- terminal.startPhase(`🚀 Launching ${activeWorkers.length} grinders!`);
3351
+ // Phase 3: Start all grind loops (only for valid workers)
3006
3352
  for (const w of activeWorkers) {
3007
3353
  if (!shutdownCalled) w.grindLoop();
3008
3354
  }
3009
- terminal.endPhase(`${activeWorkers.length} grinders active`);
3010
- terminal.setWorkers(workers);
3011
- terminal.setActive();
3355
+
3356
+ startTime = Date.now();
3357
+ dashboardStarted = true;
3358
+ setDashboardActive(true);
3359
+
3360
+ // Clear screen and position cursor at top-left before dashboard takes over
3361
+ process.stdout.write('\x1b[2J\x1b[H');
3362
+
3363
+ // Setup keyboard shortcuts
3364
+ setupKeyboardShortcuts();
3365
+
3366
+ // Re-render on terminal resize so layout adapts to window size
3367
+ process.stdout.on('resize', () => {
3368
+ process.stdout.write('\x1b[2J\x1b[H');
3369
+ dashboardLines = 0;
3370
+ scheduleRender();
3371
+ });
3372
+
3373
+ setInterval(() => scheduleRender(), 1000);
3374
+ scheduleRender();
3012
3375
 
3013
3376
  // Cluster heartbeat — lets other nodes see this node is alive
3014
3377
  if (CLUSTER_ENABLED) {
@@ -3065,7 +3428,7 @@ async function start(apiKey, apiUrl, opts = {}) {
3065
3428
  const before = workers.length;
3066
3429
  // Keep ALL workers visible — never remove from array (user wants to see gaps)
3067
3430
  // Only clean up workerMap entries for accounts fully removed from API
3068
- if (workers.length !== before) { /* workers changed */ }
3431
+ if (workers.length !== before) scheduleRender();
3069
3432
  } catch {}
3070
3433
  }, 10_000);
3071
3434
 
@@ -3074,27 +3437,44 @@ async function start(apiKey, apiUrl, opts = {}) {
3074
3437
  if (sigintHandled) return;
3075
3438
  sigintHandled = true;
3076
3439
  shutdownCalled = true;
3440
+ dashboardStarted = false;
3077
3441
  setDashboardActive(false);
3442
+ process.stdout.write(c.show);
3078
3443
 
3079
- // Collect stats for summary
3080
- let finalCoins = 0, finalCmds = 0, totalSuccess = 0;
3444
+ if (dashboardLines > 0) {
3445
+ process.stdout.write(c.cursorUp(dashboardLines));
3446
+ for (let i = 0; i < dashboardLines; i++) {
3447
+ process.stdout.write(c.clearLine + '\r\n');
3448
+ }
3449
+ process.stdout.write(c.cursorUp(dashboardLines));
3450
+ }
3451
+
3452
+ const sepBar = rgb(139, 92, 246) + c.bold + '═'.repeat(tw) + c.reset;
3453
+ console.log('');
3454
+ console.log(` ${rgb(251, 191, 36)}${c.bold}Session Summary${c.reset}`);
3455
+ console.log(sepBar);
3456
+
3457
+ // Collect stats from all workers (including rotated-out ones)
3458
+ let finalCoins = 0;
3459
+ let finalCmds = 0;
3081
3460
  for (const wk of workers) {
3461
+ const rate = wk.stats.commands > 0 ? ((wk.stats.successes / wk.stats.commands) * 100).toFixed(0) : 0;
3462
+ console.log(
3463
+ ` ${wk.color}${c.bold}${(wk.username || '?').padEnd(18)}${c.reset}` +
3464
+ ` ${rgb(52, 211, 153)}+⏣ ${wk.stats.coins.toLocaleString().padStart(8)}${c.reset}` +
3465
+ ` ${c.dim}${wk.stats.commands.toString().padStart(4)} cmds${c.reset}` +
3466
+ ` ${c.dim}${rate}% success${c.reset}`
3467
+ );
3082
3468
  finalCoins += wk.stats.coins || 0;
3083
3469
  finalCmds += wk.stats.commands || 0;
3084
- totalSuccess += wk.stats.successes || 0;
3085
3470
  }
3471
+ console.log(sepBar);
3472
+
3086
3473
  const memFinal = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3087
- const uptimeMs = Date.now() - startTime;
3088
-
3089
- // Show beautiful session summary via terminal renderer
3090
- terminal.shutdown({
3091
- totalCoins: finalCoins,
3092
- totalCmds: finalCmds,
3093
- totalSuccess: finalCmds > 0 ? ((totalSuccess / finalCmds) * 100).toFixed(0) : 0,
3094
- workers,
3095
- uptime: uptimeMs,
3096
- memMB: memFinal,
3097
- });
3474
+ const avgEarn = globalEarningsEMA.get();
3475
+ const cpm = globalCmdRate.getRate().toFixed(1);
3476
+ console.log(` ${rgb(251, 191, 36)}${c.bold}Total: +⏣ ${finalCoins.toLocaleString()}${c.reset} ${c.dim}in ${formatUptime()} | ${finalCmds} cmds | ~${cpm} cmd/m | ${memFinal}MB | avg earn ⏣ ${Math.round(avgEarn)}${c.reset}`);
3477
+ console.log('');
3098
3478
 
3099
3479
  // Release all cluster claims before stopping workers
3100
3480
  const releasePromises = workers.map(wk => releaseClaim(wk.account.id).catch(() => {}));
@@ -3110,19 +3490,99 @@ async function start(apiKey, apiUrl, opts = {}) {
3110
3490
 
3111
3491
  const totalRecoveries = workers.reduce((sum, wk) => sum + (wk._totalRecoveries || 0), 0);
3112
3492
  const totalDisconnects = workers.reduce((sum, wk) => sum + (wk._disconnectCount || 0), 0);
3493
+ const totalRateLimits = workers.reduce((sum, wk) => sum + (wk._rateLimitHits || 0), 0);
3113
3494
 
3114
3495
  const webhookMsg = `+⏣ ${finalCoins.toLocaleString()} | ${finalCmds} cmds | ${formatUptime()}` +
3115
3496
  (totalRecoveries > 0 ? ` | ${totalRecoveries} auto-recoveries` : '') +
3116
3497
  (CLUSTER_ENABLED ? ` | node: ${NODE_ID.substring(0, 12)}` : '');
3117
3498
  sendWebhook('Session Ended', webhookMsg, 0x8b5cf6);
3118
3499
 
3500
+ if (totalRecoveries > 0 || totalDisconnects > 0) {
3501
+ console.log(` ${c.dim}Recovery stats: ${totalRecoveries} auto-recoveries, ${totalDisconnects} disconnects, ${totalRateLimits} rate limits${c.reset}`);
3502
+ }
3503
+ if (CLUSTER_ENABLED) {
3504
+ console.log(` ${c.dim}Cluster node: ${NODE_ID} — claims released${c.reset}`);
3505
+ }
3506
+
3119
3507
  if (redis) { try { redis.disconnect(); } catch {} }
3120
- setTimeout(() => process.exit(0), 500);
3508
+ setTimeout(() => process.exit(0), 1500);
3121
3509
  });
3122
3510
  }
3123
3511
 
3124
3512
  // ══════════════════════════════════════════════════════════════
3125
- // Keyboard shortcuts removed no display to update
3513
+ // Keyboard Shortcuts (Quality of Life)
3514
+ // ══════════════════════════════════════════════════════════════
3515
+ // Single-key shortcuts for common actions
3516
+ function setupKeyboardShortcuts() {
3517
+ if (process.stdin.isTTY) {
3518
+ process.stdin.setRawMode(true);
3519
+ process.stdin.resume();
3520
+ process.stdin.setEncoding('utf8');
3521
+
3522
+ // Premium styled keyboard shortcuts with gradient box
3523
+ const accent = rgb(139, 92, 246);
3524
+ const dim = c.dim;
3525
+ const kw = 60;
3526
+ console.log('');
3527
+ console.log(` ${accent}╭${'─'.repeat(kw)}╮${c.reset}`);
3528
+ console.log(` ${accent}│${c.reset} ${gradientText('KEYBOARD SHORTCUTS', [192, 132, 252], [52, 211, 153])}${' '.repeat(kw - 20)}${accent}│${c.reset}`);
3529
+ console.log(` ${accent}├${'─'.repeat(kw)}┤${c.reset}`);
3530
+ console.log(` ${accent}│${c.reset} ${rgb(96, 165, 250)}P${c.reset} ${dim}Pause all${c.reset} ${rgb(52, 211, 153)}R${c.reset} ${dim}Resume all${c.reset} ${rgb(251, 191, 36)}S${c.reset} ${dim}Status${c.reset} ${rgb(239, 68, 68)}Q${c.reset} ${dim}Quit${c.reset}${' '.repeat(Math.max(0, kw - 54))}${accent}│${c.reset}`);
3531
+ console.log(` ${accent}╰${'─'.repeat(kw)}╯${c.reset}`);
3532
+ console.log('');
3533
+
3534
+ process.stdin.on('data', (key) => {
3535
+ const k = key.toString().toLowerCase();
3536
+
3537
+ // Ctrl+C or q = quit
3538
+ if (k === '\u0003' || k === 'q') {
3539
+ process.stdout.write(c.show);
3540
+ console.log(`\n\n ${c.yellow}Shutting down gracefully...${c.reset}`);
3541
+ process.emit('SIGINT');
3542
+ return;
3543
+ }
3544
+
3545
+ // p = pause all accounts
3546
+ if (k === 'p') {
3547
+ let count = 0;
3548
+ workers.forEach(w => { if (w.running && !w.paused) { w.paused = true; count++; } });
3549
+ recentLogs.push(`>> PAUSED ${count} accounts (press R to resume)`);
3550
+ scheduleRender();
3551
+ return;
3552
+ }
3553
+
3554
+ // r = resume all accounts
3555
+ if (k === 'r') {
3556
+ let count = 0;
3557
+ workers.forEach(w => { if (w.paused) { w.paused = false; count++; } });
3558
+ recentLogs.push(`>> RESUMED ${count} accounts`);
3559
+ scheduleRender();
3560
+ return;
3561
+ }
3562
+
3563
+ // s = show status summary (pushed to log feed)
3564
+ if (k === 's') {
3565
+ const active = workers.filter(w => w.running && !w.paused).length;
3566
+ const paused = workers.filter(w => w.paused).length;
3567
+ const invalid = workers.filter(w => w._tokenInvalid).length;
3568
+ const offline = workers.filter(w => !w.running && !w._tokenInvalid).length;
3569
+ const recovering = workers.filter(w => w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()).length;
3570
+ const totalEarn = workers.reduce((s, w) => s + (w.stats.coins || 0), 0);
3571
+ recentLogs.push(`>> STATUS: ${active} active, ${paused} paused, ${invalid} invalid, ${offline} offline, ${recovering} recovering`);
3572
+ recentLogs.push(`>> EARNINGS: +${formatCoins(totalEarn)} this session | BALANCE: ${formatCoins(totalBalance)}`);
3573
+ scheduleRender();
3574
+ return;
3575
+ }
3576
+
3577
+ // ? or h = show help
3578
+ if (k === '?' || k === 'h') {
3579
+ recentLogs.push('>> SHORTCUTS: P=pause R=resume S=status Q=quit ?=help');
3580
+ scheduleRender();
3581
+ return;
3582
+ }
3583
+ });
3584
+ }
3585
+ }
3126
3586
 
3127
3587
  // Export the start function for CLI
3128
3588
  module.exports = { start };