dankgrinder 6.1.0 → 6.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.
Files changed (2) hide show
  1. package/lib/grinder.js +449 -233
  2. package/package.json +1 -1
package/lib/grinder.js CHANGED
@@ -219,23 +219,69 @@ function gradientLine(text, from, to) {
219
219
  return out + c.reset;
220
220
  }
221
221
 
222
+ function gradientText(text, from, to) {
223
+ return gradientLine(text, from, to);
224
+ }
225
+
222
226
  // ── Sparkline graph for earnings trend ───────────────────────
223
227
  const SPARK_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
224
228
  function drawSparkline(data, width = 12) {
225
- if (!data || data.length === 0) return c.dim + '──────' + c.reset;
229
+ if (!data || data.length === 0) return c.dim + '·····' + c.reset;
226
230
  const recent = data.slice(-width);
227
231
  const min = Math.min(...recent);
228
232
  const max = Math.max(...recent);
229
233
  const range = max - min || 1;
230
234
  return recent.map(v => {
231
235
  const idx = Math.min(7, Math.floor(((v - min) / range) * 8));
232
- const char = SPARK_CHARS[idx] || '▁';
233
- return v >= max * 0.9 ? rgb(52, 211, 153) + char + c.reset :
234
- v <= min * 1.1 ? rgb(239, 68, 68) + char + c.reset :
235
- rgb(139, 92, 246) + char + c.reset;
236
+ const ch = SPARK_CHARS[idx] || '▁';
237
+ const t = (v - min) / range;
238
+ // Gradient from red->yellow->green based on relative value
239
+ const r = t < 0.5 ? 239 : lerp(251, 52, (t - 0.5) * 2);
240
+ const g = t < 0.5 ? lerp(68, 191, t * 2) : lerp(191, 211, (t - 0.5) * 2);
241
+ const b = t < 0.5 ? lerp(68, 36, t * 2) : lerp(36, 153, (t - 0.5) * 2);
242
+ return rgb(r, g, b) + ch + c.reset;
236
243
  }).join('');
237
244
  }
238
245
 
246
+ // ── Advanced progress bar ────────────────────────────────────
247
+ function progressBar(value, max, width, filledColor, emptyColor) {
248
+ const pct = max > 0 ? Math.min(1, value / max) : 0;
249
+ const filled = Math.round(pct * width);
250
+ const empty = width - filled;
251
+ const fc = filledColor || [52, 211, 153];
252
+ const ec = emptyColor || [50, 50, 70];
253
+ return rgb(fc[0], fc[1], fc[2]) + '█'.repeat(filled) + rgb(ec[0], ec[1], ec[2]) + '░'.repeat(empty) + c.reset;
254
+ }
255
+
256
+ // ── Animated braille spinner frames ──────────────────────────
257
+ const BRAILLE_SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
258
+ const BLOCK_SPIN = ['▉', '▊', '▋', '▌', '▍', '▎', '▏', '▎', '▍', '▌', '▋', '▊'];
259
+ const PULSE_CHARS = ['○', '◎', '●', '◉', '●', '◎'];
260
+ function getSpinner(type = 'braille') {
261
+ const now = Math.floor(Date.now() / 80);
262
+ if (type === 'block') return BLOCK_SPIN[now % BLOCK_SPIN.length];
263
+ if (type === 'pulse') return PULSE_CHARS[now % PULSE_CHARS.length];
264
+ return BRAILLE_SPIN[now % BRAILLE_SPIN.length];
265
+ }
266
+
267
+ // ── Box drawing helpers ──────────────────────────────────────
268
+ const BOX = {
269
+ tl: '╭', tr: '╮', bl: '╰', br: '╯',
270
+ h: '─', v: '│', hBold: '━', vBold: '┃',
271
+ dtl: '╔', dtr: '╗', dbl: '╚', dbr: '╝', dh: '═', dv: '║',
272
+ cross: '┼', tee: '├', teeR: '┤', teeD: '┬', teeU: '┴',
273
+ };
274
+
275
+ function boxTop(w, color) { return color + BOX.dtl + BOX.dh.repeat(w - 2) + BOX.dtr + c.reset; }
276
+ function boxMid(w, color) { return color + BOX.tee + BOX.h.repeat(w - 2) + BOX.teeR + c.reset; }
277
+ function boxBot(w, color) { return color + BOX.dbl + BOX.dh.repeat(w - 2) + BOX.dbr + c.reset; }
278
+ function boxLine(content, w, color) {
279
+ const stripped = content.replace(/\x1b\[[0-9;]*m/g, '');
280
+ const pad = Math.max(0, w - 4 - stripped.length);
281
+ return color + BOX.dv + c.reset + ' ' + content + ' '.repeat(pad) + ' ' + color + BOX.dv + c.reset;
282
+ }
283
+ function thinLine(w) { return ' ' + c.dim + BOX.h.repeat(w - 4) + c.reset; }
284
+
239
285
  const BANNER_RAW = [
240
286
  ' ██████╗ █████╗ ███╗ ██╗██╗ ██╗',
241
287
  ' ██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝',
@@ -284,24 +330,30 @@ let shutdownCalled = false;
284
330
  let sessionPeakCoins = 0;
285
331
  let isNewHigh = false;
286
332
  // RingBuffer: O(1) push, bounded memory, no array shifting or GC pressure
287
- const recentLogs = new RingBuffer(6);
288
- const MAX_LOGS = 6;
289
- const RENDER_THROTTLE_MS = 250;
333
+ const recentLogs = new RingBuffer(8);
334
+ const MAX_LOGS = 8;
335
+ const RENDER_THROTTLE_MS = 200;
290
336
  // Earnings history for sparkline (sample every 10 seconds)
291
- const earningsHistory = new RingBuffer(20);
337
+ const earningsHistory = new RingBuffer(30);
292
338
  let lastEarningsSample = 0;
339
+ // Per-command stats tracking
340
+ const cmdStats = new Map();
341
+ // Coins per minute history for rate graph
342
+ const cpmHistory = new RingBuffer(20);
343
+ let lastCpmSample = 0;
293
344
 
294
345
  function formatUptime() {
295
346
  const s = Math.floor((Date.now() - startTime) / 1000);
296
347
  const h = Math.floor(s / 3600);
297
348
  const m = Math.floor((s % 3600) / 60);
298
349
  const sec = s % 60;
299
- if (h > 0) return `${h}h ${m}m`;
350
+ if (h > 0) return `${h}h ${m}m ${sec}s`;
300
351
  if (m > 0) return `${m}m ${sec}s`;
301
352
  return `${sec}s`;
302
353
  }
303
354
 
304
355
  function formatCoins(n) {
356
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
305
357
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
306
358
  if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
307
359
  return n.toLocaleString();
@@ -326,14 +378,15 @@ function renderDashboard() {
326
378
 
327
379
  totalBalance = 0; totalCoins = 0; totalCommands = 0;
328
380
  let totalErrors = 0;
381
+ let totalLosses = 0;
329
382
  for (const w of workers) {
330
383
  totalBalance += (w.stats.balance || 0) + (w.stats.bankBalance || 0);
331
384
  totalCoins += w.stats.coins || 0;
332
385
  totalCommands += w.stats.commands || 0;
333
386
  totalErrors += w.stats.errors || 0;
387
+ totalLosses += w.stats.losses || 0;
334
388
  }
335
389
  const successRate = totalCommands > 0 ? Math.round(((totalCommands - totalErrors) / totalCommands) * 100) : 100;
336
- // Track session peak and new high
337
390
  if (totalCoins > sessionPeakCoins) {
338
391
  sessionPeakCoins = totalCoins;
339
392
  isNewHigh = true;
@@ -341,212 +394,225 @@ function renderDashboard() {
341
394
  }
342
395
 
343
396
  const lines = [];
344
- const tw = Math.min(process.stdout.columns || 80, 78);
345
- const thinBar = c.dim + '─'.repeat(tw) + c.reset;
346
- const bar = rgb(139, 92, 246) + c.bold + '━'.repeat(tw) + c.reset;
347
- const doubleBar = rgb(139, 92, 246) + '╔' + '═'.repeat(tw - 2) + '╗' + c.reset;
348
- const doubleBarMid = rgb(139, 92, 246) + '╠' + '═'.repeat(tw - 2) + '╣' + c.reset;
349
- const doubleBarBot = rgb(139, 92, 246) + '╚' + '═'.repeat(tw - 2) + '╝' + c.reset;
350
-
351
- // Header with title, stats, and mode
352
- lines.push(doubleBar);
353
- const cmdCount = AccountWorker.COMMAND_MAP.length;
397
+ const tw = Math.max(Math.min(process.stdout.columns || 80, 90), 60);
398
+ const accent = rgb(139, 92, 246);
399
+ const accentDim = rgb(90, 60, 170);
400
+ const green = rgb(52, 211, 153);
401
+ const blue = rgb(96, 165, 250);
402
+ const pink = rgb(236, 72, 153);
403
+ const gold = rgb(255, 215, 0);
404
+ const orange = rgb(251, 146, 60);
405
+ const cyan = rgb(34, 211, 238);
406
+ const red = rgb(239, 68, 68);
407
+ const yellow = rgb(251, 191, 36);
408
+ const white = c.white;
409
+ const dim = c.dim;
410
+ const RE_ANSI = /\x1b\[[0-9;]*m/g;
411
+
412
+ // ━━━ HEADER BOX ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
413
+ lines.push(boxTop(tw, accent));
414
+
415
+ // Title line with animated spinner
416
+ const spin = getSpinner('braille');
417
+ const titleGrad = gradientText('DankGrinder', [192, 132, 252], [52, 211, 153]);
418
+ const verStr = `${dim}v${PKG_VERSION}${c.reset}`;
419
+ lines.push(boxLine(`${c.bold}${titleGrad}${c.reset} ${verStr} ${green}${spin}${c.reset}`, tw, accent));
420
+
421
+ // Info bar
354
422
  const activeCount = workers.filter(w => w.running && !w.paused && !w.dashboardPaused).length;
355
- const mode = CLUSTER_ENABLED ? `${rgb(34, 211, 238)}Cluster${c.reset}` : `${c.dim}Standalone${c.reset}`;
356
-
357
- // Animated spinner and gradient title
358
- const spinners = ['◐', '◓', '◑', '◒'];
359
- const spinner = spinners[Math.floor(Date.now() / 250) % 4];
360
- const animatedSpinner = `${rgb(52, 211, 153)}${spinner}${c.reset}`;
361
- const gradientTitle = gradientLine('DankGrinder', [192, 132, 252], [34, 211, 238]);
362
-
363
- // Network quality indicator (based on recent latency/errors)
364
- const netQuality = workers.length > 0
365
- ? workers.reduce((s, w) => s + (w._lastPing < 200 ? 1 : w._lastPing < 500 ? 0.5 : 0), 0) / workers.length
366
- : 1;
367
- const netIcon = netQuality > 0.8 ? `${rgb(52, 211, 153)}◉${c.reset}` :
368
- netQuality > 0.5 ? `${rgb(251, 191, 36)}◉${c.reset}` :
369
- `${rgb(239, 68, 68)}◉${c.reset}`;
370
- const netLabel = netQuality > 0.8 ? `${c.dim}good${c.reset}` :
371
- netQuality > 0.5 ? `${c.dim}fair${c.reset}` :
372
- `${c.dim}poor${c.reset}`;
373
-
374
- lines.push(` ${c.bold}${gradientTitle}${c.reset} ${c.dim}v${PKG_VERSION}${c.reset}`);
375
- lines.push(` ${mode} ${c.dim}·${c.reset} ${netIcon} ${netLabel} ${c.dim}·${c.reset} ${c.white}${cmdCount} commands${c.reset} ${c.dim}·${c.reset} ${animatedSpinner} ${rgb(52, 211, 153)}${activeCount}${c.reset}${c.dim}/${c.reset}${c.white}${workers.length}${c.reset} ${c.dim}live${c.reset}`);
376
-
377
- // Stats row 1: Balance & Earnings
378
- lines.push(doubleBarMid);
379
- const liveIcon = rgb(52, 211, 153) + '●' + c.reset;
380
- const balStr = `${rgb(192, 132, 252)}${c.bold}⏣ ${formatCoins(totalBalance)}${c.reset}`;
381
- const earnStr = `${rgb(52, 211, 153)}▲ ${formatCoins(totalCoins)}${c.reset}`;
423
+ const pausedCount = workers.filter(w => w.paused || w.dashboardPaused).length;
424
+ const recovCount = workers.filter(w => w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()).length;
425
+ const mode = CLUSTER_ENABLED ? `${cyan}CLUSTER${c.reset}` : `${dim}STANDALONE${c.reset}`;
426
+ const cmdCount = AccountWorker.COMMAND_MAP.length;
427
+
428
+ // Net quality
429
+ const netQ = workers.length > 0
430
+ ? workers.reduce((s, w) => s + (w._lastPing < 200 ? 1 : w._lastPing < 500 ? 0.5 : 0), 0) / workers.length : 1;
431
+ const netDot = netQ > 0.8 ? `${green}◉${c.reset}` : netQ > 0.5 ? `${yellow}◉${c.reset}` : `${red}◉${c.reset}`;
432
+ const netLbl = netQ > 0.8 ? `${green}GOOD${c.reset}` : netQ > 0.5 ? `${yellow}FAIR${c.reset}` : `${red}POOR${c.reset}`;
433
+
434
+ lines.push(boxLine(`${mode} ${dim}|${c.reset} ${netDot} ${netLbl} ${dim}|${c.reset} ${blue}${cmdCount}${c.reset} ${dim}cmds${c.reset} ${dim}|${c.reset} ${green}${activeCount}${c.reset}${dim}/${c.reset}${white}${workers.length}${c.reset} ${dim}live${c.reset}${pausedCount > 0 ? ` ${yellow}${pausedCount} paused${c.reset}` : ''}${recovCount > 0 ? ` ${orange}${recovCount} recovering${c.reset}` : ''}`, tw, accent));
435
+
436
+ // ━━━ STATS SECTION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
437
+ const midLine = accent + BOX.tee + BOX.h.repeat(tw - 2) + BOX.teeR + c.reset;
438
+ lines.push(midLine);
439
+
440
+ // Balance card
441
+ const balIcon = `${gold}⟐${c.reset}`;
442
+ const balVal = `${c.bold}${gold} ${formatCoins(totalBalance)}${c.reset}`;
443
+ const peakTag = isNewHigh ? ` ${red}${c.bold}<< NEW HIGH >>${c.reset}` : '';
444
+ lines.push(boxLine(`${balIcon} ${dim}BALANCE${c.reset} ${balVal}${peakTag}`, tw, accent));
445
+
446
+ // Earnings card with sparkline
382
447
  const elapsedHrs = (Date.now() - startTime) / 3_600_000;
383
- const coinsPerHr = elapsedHrs > 0.01 ? Math.round(totalCoins / elapsedHrs) : 0;
384
- const rateLabel = `${rgb(52, 211, 153)}${formatCoins(coinsPerHr)}/h${c.reset}`;
385
-
386
- // Stats row 2: Commands & Performance
387
- const cmdStr = `${rgb(96, 165, 250)}${totalCommands}${c.reset}${c.dim} cmds${c.reset}`;
388
- const rateStr = successRate >= 95
389
- ? `${rgb(52, 211, 153)}● ${successRate}%${c.reset}`
390
- : successRate >= 80 ? `${rgb(251, 191, 36)}● ${successRate}%${c.reset}`
391
- : `${rgb(239, 68, 68)}${successRate}%${c.reset}`;
448
+ const perHr = elapsedHrs > 0.01 ? Math.round(totalCoins / elapsedHrs) : 0;
449
+ const earnIcon = `${green}▲${c.reset}`;
450
+ const earnVal = `${c.bold}${green}+⏣ ${formatCoins(totalCoins)}${c.reset}`;
451
+ const hrRate = `${dim}(${c.reset}${green}${formatCoins(perHr)}/h${c.reset}${dim})${c.reset}`;
452
+ // Sample sparkline
453
+ const now = Date.now();
454
+ if (now - lastEarningsSample > 8000) { earningsHistory.push(totalCoins); lastEarningsSample = now; }
455
+ const spark = drawSparkline(earningsHistory.toArray(), 20);
456
+ lines.push(boxLine(`${earnIcon} ${dim}EARNED${c.reset} ${earnVal} ${hrRate} ${spark}`, tw, accent));
457
+
458
+ // Peak earnings
459
+ const peakIcon = `${orange}★${c.reset}`;
460
+ lines.push(boxLine(`${peakIcon} ${dim}PEAK${c.reset} ${c.bold}${orange}⏣ ${formatCoins(sessionPeakCoins)}${c.reset} ${dim}this session${c.reset}`, tw, accent));
461
+
462
+ // Performance metrics line
392
463
  const cpmVal = globalCmdRate.getRate().toFixed(1);
393
- const cpmStr = `${rgb(34, 211, 238)}${cpmVal}${c.reset}${c.dim}/m${c.reset}`;
464
+ const srColor = successRate >= 95 ? green : successRate >= 80 ? yellow : red;
465
+ const srBar = progressBar(successRate, 100, 8, successRate >= 95 ? [52, 211, 153] : successRate >= 80 ? [251, 191, 36] : [239, 68, 68]);
466
+ const perfIcon = `${blue}◆${c.reset}`;
467
+ lines.push(boxLine(`${perfIcon} ${dim}PERF${c.reset} ${blue}${totalCommands}${c.reset} ${dim}cmds${c.reset} ${srColor}${successRate}%${c.reset} ${srBar} ${cyan}${cpmVal}${c.reset}${dim}/min${c.reset} ${yellow}◷${c.reset} ${yellow}${formatUptime()}${c.reset}`, tw, accent));
394
468
 
395
- // Stats row 3: Uptime & Memory
396
- const upStr = `${rgb(251, 191, 36)}◷ ${formatUptime()}${c.reset}`;
469
+ // Memory line
397
470
  const memMB = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
398
- const memPct = Math.min(100, (memMB / 1024) * 100);
399
- const memBarWidth = Math.floor((memPct / 100) * 10);
400
- const memBar = rgb(52, 211, 153) + '▅'.repeat(memBarWidth) + c.dim + '▅'.repeat(10 - memBarWidth) + c.reset;
401
- const memColor = memMB > 900 ? rgb(239, 68, 68) : memMB > 600 ? rgb(251, 191, 36) : rgb(52, 211, 153);
402
- const memStr = `${memColor}${memMB}MB${c.reset} ${memBar}`;
471
+ const memColor = memMB > 900 ? [239, 68, 68] : memMB > 600 ? [251, 191, 36] : [52, 211, 153];
472
+ const memBar = progressBar(memMB, 1024, 12, memColor, [40, 40, 55]);
473
+ const memIcon = `${dim}≡${c.reset}`;
474
+ lines.push(boxLine(`${memIcon} ${dim}MEM${c.reset} ${rgb(memColor[0], memColor[1], memColor[2])}${memMB}MB${c.reset} ${memBar}`, tw, accent));
403
475
 
404
- // Sample earnings for sparkline every 10 seconds
405
- const now = Date.now();
406
- if (now - lastEarningsSample > 10000) {
407
- earningsHistory.push(totalCoins);
408
- lastEarningsSample = now;
409
- }
410
- const sparkline = drawSparkline(earningsHistory.toArray(), 16);
411
- const peakIndicator = isNewHigh ? `${rgb(255, 100, 100)} 🚀 NEW HIGH!${c.reset}` : '';
412
-
413
- lines.push(` ${liveIcon} ${balStr} ${c.dim}│${c.reset} ${earnStr} ${c.dim}(${c.reset}${rateLabel}${c.dim})${c.reset}${peakIndicator}`);
414
- lines.push(` ${cmdStr} ${c.dim}(${c.reset}${rateStr}${c.dim})${c.reset} ${c.dim}│${c.reset} ${cpmStr} ${c.dim}│${c.reset} ${upStr}`);
415
- lines.push(` ${c.dim}Peak:${c.reset} ${rgb(255, 215, 0)}⏣ ${formatCoins(sessionPeakCoins)}${c.reset} ${c.dim}│${c.reset} Trend: ${sparkline} ${c.dim}│${c.reset} ${memStr}`);
416
- lines.push(thinBar);
417
-
418
- // Worker rows — paginated for 10K+ accounts
419
- // Renders up to MAX_VISIBLE_WORKERS rows individually, then shows
420
- // a compact summary for the rest. This keeps terminal responsive.
421
- const MAX_VISIBLE_WORKERS = 30;
422
- const nameWidth = Math.min(18, tw > 70 ? 18 : 12);
423
- const statusWidth = Math.max(18, tw - nameWidth - 42);
424
- const RE_ANSI_STRIP = /\x1b\[[0-9;]*m/g;
425
-
426
- // Find top 3 earners for highlighting
476
+ // ━━━ ACCOUNTS TABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
477
+ lines.push(midLine);
478
+
479
+ const nameW = Math.min(20, tw > 75 ? 20 : 14);
480
+ const statusW = Math.max(20, tw - nameW - 44);
481
+
482
+ // Column headers with gradient
483
+ const colHead = ` ${dim}##${c.reset} ${dim}STS${c.reset} ${gradientText('Account', [139, 92, 246], [96, 165, 250])}${' '.repeat(Math.max(0, nameW - 7))} ${dim}Balance${c.reset} ${dim}Earned${c.reset} ${dim}Activity${c.reset}`;
484
+ lines.push(boxLine(colHead, tw, accent));
485
+ lines.push(boxLine(`${dim}${'─'.repeat(tw - 6)}${c.reset}`, tw, accent));
486
+
487
+ // Top 3 earners
427
488
  const topEarners = [...workers]
428
489
  .filter(w => w.running && (w.stats.coins || 0) > 0)
429
490
  .sort((a, b) => (b.stats.coins || 0) - (a.stats.coins || 0))
430
491
  .slice(0, 3);
431
- const topEarnerIds = new Set(topEarners.map(w => w.account.id));
432
-
433
- // Column headers
434
- lines.push(` ${c.dim}#${c.reset} ${c.dim}Account${' '.repeat(nameWidth - 5)}${c.reset} ${c.dim}Balance${c.reset} ${c.dim}Earned${c.reset} ${c.dim}Status${c.reset}`);
435
- lines.push(` ${c.dim}${'─'.repeat(tw - 4)}${c.reset}`);
436
-
437
- const renderWorkerRow = (wk, index) => {
438
- const pos = `${c.dim}#${c.reset}${c.bold}${(index + 1).toString().padStart(2, ' ')}${c.reset}`;
439
- const rawStatus = (wk.lastStatus || 'idle').replace(RE_ANSI_STRIP, '');
440
- const last = rawStatus.substring(0, statusWidth);
441
-
442
- let dot, stateLabel;
443
- const isRecovering = wk._recoveryAttempts > 0 && wk._errorCooldownUntil > Date.now();
444
- if (!wk.running) {
445
- dot = `${rgb(239, 68, 68)}○${c.reset}`;
446
- stateLabel = `${c.dim}offline${c.reset}`;
447
- } else if (isRecovering) {
448
- const sLeft = Math.ceil((wk._errorCooldownUntil - Date.now()) / 1000);
449
- dot = `${rgb(251, 191, 36)}↻${c.reset}`;
450
- stateLabel = `${rgb(251, 191, 36)}recovering #${wk._recoveryAttempts} (${sLeft}s)${c.reset}`;
492
+ const topIds = new Set(topEarners.map(w => w.account.id));
493
+
494
+ const MAX_VIS = 30;
495
+
496
+ const renderRow = (wk, idx) => {
497
+ // Use original account number (wk.idx) so removed accounts leave visible gaps
498
+ const origNum = wk.idx + 1;
499
+ const num = `${dim}${origNum.toString().padStart(2)}${c.reset}`;
500
+ const rawStat = (wk.lastStatus || 'idle').replace(RE_ANSI, '');
501
+ const statTxt = rawStat.substring(0, statusW);
502
+
503
+ // Status indicator with animations
504
+ let sts, statLabel;
505
+ const isRecov = wk._recoveryAttempts > 0 && wk._errorCooldownUntil > Date.now();
506
+ if (wk._tokenInvalid) {
507
+ sts = `${red}✗${c.reset}`;
508
+ statLabel = `${red}TOKEN INVALID${c.reset}`;
509
+ } else if (!wk.running) {
510
+ sts = `${dim}○${c.reset}`;
511
+ statLabel = `${dim}offline${c.reset}`;
512
+ } else if (isRecov) {
513
+ const sL = Math.ceil((wk._errorCooldownUntil - Date.now()) / 1000);
514
+ sts = `${orange}${getSpinner('braille')}${c.reset}`;
515
+ statLabel = `${orange}recovering #${wk._recoveryAttempts} (${sL}s)${c.reset}`;
451
516
  } else if (wk.paused) {
452
- dot = `${rgb(239, 68, 68)}⏸${c.reset}`;
453
- stateLabel = `${rgb(239, 68, 68)}PAUSED${c.reset}`;
517
+ sts = `${red}⏸${c.reset}`;
518
+ statLabel = `${red}PAUSED${c.reset}`;
454
519
  } else if (wk.dashboardPaused) {
455
- dot = `${rgb(251, 191, 36)}⏸${c.reset}`;
456
- stateLabel = `${rgb(251, 191, 36)}paused${c.reset}`;
520
+ sts = `${yellow}⏸${c.reset}`;
521
+ statLabel = `${yellow}paused (user)${c.reset}`;
457
522
  } else if (wk.busy) {
458
- dot = `${rgb(251, 191, 36)}◉${c.reset}`;
459
- stateLabel = `${c.dim}${last}${c.reset}`;
523
+ sts = `${green}${getSpinner('pulse')}${c.reset}`;
524
+ statLabel = `${dim}${statTxt}${c.reset}`;
460
525
  } else {
461
- dot = `${rgb(52, 211, 153)}●${c.reset}`;
462
- stateLabel = `${c.dim}${last}${c.reset}`;
526
+ sts = `${green}●${c.reset}`;
527
+ statLabel = `${dim}${statTxt}${c.reset}`;
463
528
  }
464
529
 
465
- // Top earner medal
466
- let medal = ' ';
467
- if (topEarnerIds.has(wk.account.id)) {
530
+ // Medal for top earners
531
+ let medal = ' ';
532
+ if (topIds.has(wk.account.id)) {
468
533
  const rank = topEarners.findIndex(e => e.account.id === wk.account.id);
469
- medal = rank === 0 ? `${rgb(255, 215, 0)}🥇${c.reset}` : rank === 1 ? `${rgb(192, 192, 192)}🥈${c.reset}` : `${rgb(205, 127, 50)}🥉${c.reset}`;
534
+ // Use text medals instead of emoji for better terminal compat
535
+ medal = rank === 0 ? `${gold}1st${c.reset}` : rank === 1 ? `${rgb(192, 192, 192)}2nd${c.reset}` : `${rgb(205, 127, 50)}3rd${c.reset}`;
470
536
  }
471
537
 
472
- const name = `${wk.color}${c.bold}${(wk.username || '?').substring(0, nameWidth)}${c.reset}`;
538
+ const name = `${wk.color}${c.bold}${(wk.username || '?').substring(0, nameW).padEnd(nameW)}${c.reset}`;
539
+
540
+ // Balance with icon
473
541
  const bal = wk.stats.balance > 0
474
- ? `${rgb(251, 191, 36)}⏣${c.reset} ${c.white}${formatCoins(wk.stats.balance).padStart(8)}${c.reset}`
475
- : `${c.dim}⏣ -${c.reset}`;
476
-
477
- // Enhanced progress bar for earned coins
478
- const earnedNum = wk.stats.coins || 0;
479
- const earnedBarWidth = earnedNum > 0 ? Math.min(5, Math.max(1, Math.floor(Math.log10(earnedNum + 1)))) : 0;
480
- const earnedBar = earnedNum > 0
481
- ? `${rgb(52, 211, 153)}${'▰'.repeat(earnedBarWidth)}${c.dim}${'▱'.repeat(5 - earnedBarWidth)}${c.reset}`
482
- : `${c.dim}▱▱▱▱▱${c.reset}`;
483
- const earned = earnedNum > 0
484
- ? `${rgb(52, 211, 153)}+${formatCoins(earnedNum)}${c.reset} ${earnedBar}`
485
- : `${c.dim}+0${c.reset} ${earnedBar}`;
486
-
487
- lines.push(` ${pos} ${medal}${dot} ${name.padEnd(nameWidth + wk.color.length + c.bold.length + c.reset.length + 2)} ${bal} ${earned} ${stateLabel}`);
542
+ ? `${gold}⏣${c.reset}${white}${formatCoins(wk.stats.balance).padStart(9)}${c.reset}`
543
+ : `${dim}⏣ -${c.reset}`;
544
+
545
+ // Earnings with mini progress
546
+ const earnNum = wk.stats.coins || 0;
547
+ const earnBarW = earnNum > 0 ? Math.min(6, Math.max(1, Math.floor(Math.log10(earnNum + 1)))) : 0;
548
+ const earnBar = progressBar(earnBarW, 6, 6, [52, 211, 153], [40, 40, 55]);
549
+ const earned = earnNum > 0
550
+ ? `${green}+${formatCoins(earnNum).padEnd(7)}${c.reset}${earnBar}`
551
+ : `${dim} - ${c.reset}${progressBar(0, 6, 6)}`;
552
+
553
+ lines.push(boxLine(`${num} ${sts}${medal.padEnd(medal.length > 1 ? 3 : 1)} ${name} ${bal} ${earned} ${statLabel}`, tw, accent));
488
554
  };
489
555
 
490
- if (workers.length <= MAX_VISIBLE_WORKERS) {
491
- for (let i = 0; i < workers.length; i++) renderWorkerRow(workers[i], i);
556
+ if (workers.length <= MAX_VIS) {
557
+ for (let i = 0; i < workers.length; i++) renderRow(workers[i], i);
492
558
  } else {
493
- // Pagination: show first MAX_VISIBLE_WORKERS, summarize rest
494
- for (let i = 0; i < MAX_VISIBLE_WORKERS; i++) renderWorkerRow(workers[i], i);
495
- const remaining = workers.length - MAX_VISIBLE_WORKERS;
496
- let hiddenActive = 0, hiddenPaused = 0, hiddenRecovering = 0, hiddenOffline = 0;
497
- for (let i = MAX_VISIBLE_WORKERS; i < workers.length; i++) {
559
+ for (let i = 0; i < MAX_VIS; i++) renderRow(workers[i], i);
560
+ const rest = workers.length - MAX_VIS;
561
+ let ha = 0, hp = 0, hr = 0, ho = 0;
562
+ for (let i = MAX_VIS; i < workers.length; i++) {
498
563
  const w = workers[i];
499
- if (!w.running) hiddenOffline++;
500
- else if (w.paused || w.dashboardPaused) hiddenPaused++;
501
- else if (w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()) hiddenRecovering++;
502
- else hiddenActive++;
503
- }
504
- const parts = [`${c.dim}┃${c.reset} ${c.dim}... +${remaining} more${c.reset}`];
505
- if (hiddenActive > 0) parts.push(`${rgb(52, 211, 153)}● ${hiddenActive} active${c.reset}`);
506
- if (hiddenPaused > 0) parts.push(`${rgb(251, 191, 36)}⏸ ${hiddenPaused} paused${c.reset}`);
507
- if (hiddenRecovering > 0) parts.push(`${rgb(251, 191, 36)}↻ ${hiddenRecovering} recovering${c.reset}`);
508
- if (hiddenOffline > 0) parts.push(`${c.dim}○ ${hiddenOffline} offline${c.reset}`);
509
- lines.push(` ${parts.join(` ${c.dim}·${c.reset} `)}`);
510
- }
511
-
512
- // Recovery & status summary line with enhanced styling
513
- const recoveringCount = workers.filter(w => w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()).length;
514
- const pausedCount = workers.filter(w => w.paused).length;
564
+ if (!w.running) ho++;
565
+ else if (w.paused || w.dashboardPaused) hp++;
566
+ else if (w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()) hr++;
567
+ else ha++;
568
+ }
569
+ const parts = [`${dim}... +${rest} more${c.reset}`];
570
+ if (ha > 0) parts.push(`${green}● ${ha}${c.reset}`);
571
+ if (hp > 0) parts.push(`${yellow}⏸ ${hp}${c.reset}`);
572
+ if (hr > 0) parts.push(`${orange}↻ ${hr}${c.reset}`);
573
+ if (ho > 0) parts.push(`${dim}○ ${ho}${c.reset}`);
574
+ lines.push(boxLine(` ${parts.join(` ${dim}·${c.reset} `)}`, tw, accent));
575
+ }
576
+
577
+ // ━━━ HEALTH SUMMARY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
515
578
  const totalRecoveries = workers.reduce((s, w) => s + (w._totalRecoveries || 0), 0);
516
579
  const totalDisconnects = workers.reduce((s, w) => s + (w._disconnectCount || 0), 0);
517
- if (recoveringCount > 0 || pausedCount > 0 || totalRecoveries > 0 || totalDisconnects > 0) {
518
- const parts = [];
519
- if (recoveringCount > 0) parts.push(`${rgb(251, 191, 36)}↻ ${recoveringCount} recovering${c.reset}`);
520
- if (pausedCount > 0) parts.push(`${rgb(239, 68, 68)} ${pausedCount} paused${c.reset}`);
521
- if (totalRecoveries > 0) parts.push(`${c.dim}${totalRecoveries} auto-recovered${c.reset}`);
522
- if (totalDisconnects > 0) parts.push(`${c.dim}${totalDisconnects} reconnects${c.reset}`);
523
- lines.push(` ${c.dim}┃${c.reset} ${parts.join(` ${c.dim}·${c.reset} `)}`);
580
+ if (recovCount > 0 || pausedCount > 0 || totalRecoveries > 0 || totalDisconnects > 0) {
581
+ lines.push(boxLine(`${dim}${'─'.repeat(tw - 6)}${c.reset}`, tw, accent));
582
+ const hParts = [];
583
+ if (recovCount > 0) hParts.push(`${orange}${getSpinner('braille')} ${recovCount} recovering${c.reset}`);
584
+ if (pausedCount > 0) hParts.push(`${red}${pausedCount} paused${c.reset}`);
585
+ if (totalRecoveries > 0) hParts.push(`${dim}${totalRecoveries} auto-healed${c.reset}`);
586
+ if (totalDisconnects > 0) hParts.push(`${dim}${totalDisconnects} reconnects${c.reset}`);
587
+ lines.push(boxLine(`${dim}HEALTH${c.reset} ${hParts.join(` ${dim}·${c.reset} `)}`, tw, accent));
524
588
  }
525
589
 
526
- // Cluster info line with enhanced styling
590
+ // Cluster info
527
591
  if (CLUSTER_ENABLED) {
528
592
  const nodeShort = NODE_ID.substring(0, 12);
529
593
  const claimedCount = workers.filter(w => w.running).length;
530
- lines.push(` ${rgb(34, 211, 238)}╔${c.reset}${c.dim} Cluster: ${nodeShort} · ${claimedCount} claimed ${c.reset}${rgb(34, 211, 238)}╗${c.reset}`);
594
+ lines.push(boxLine(`${cyan}CLUSTER${c.reset} ${dim}node:${c.reset}${cyan}${nodeShort}${c.reset} ${dim}claimed:${c.reset}${white}${claimedCount}${c.reset}`, tw, accent));
531
595
  }
532
596
 
533
- // Log section
597
+ // ━━━ LIVE LOG FEED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
534
598
  const logEntries = recentLogs.toArray();
535
599
  if (logEntries.length > 0) {
536
- lines.push(thinBar);
600
+ lines.push(midLine);
601
+ const logTitle = gradientText(' LIVE FEED ', [139, 92, 246], [52, 211, 153]);
602
+ lines.push(boxLine(`${logTitle}`, tw, accent));
537
603
  for (const entry of logEntries) {
538
- lines.push(` ${c.dim}${entry}${c.reset}`);
604
+ lines.push(boxLine(`${dim}${entry}${c.reset}`, tw, accent));
539
605
  }
540
606
  }
541
607
 
542
- lines.push(doubleBarBot);
608
+ // ━━━ FOOTER ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
609
+ lines.push(boxBot(tw, accent));
543
610
 
544
- // Absolute cursor home — always draw from row 1
611
+ // Render
545
612
  process.stdout.write('\x1b[H');
546
613
  for (const line of lines) {
547
614
  process.stdout.write(c.clearLine + '\r' + line + '\n');
548
615
  }
549
- // Erase everything below the dashboard (clears ghost bars, trailing lines)
550
616
  process.stdout.write('\x1b[J');
551
617
  dashboardLines = lines.length;
552
618
  dashboardRendering = false;
@@ -2400,22 +2466,37 @@ class AccountWorker {
2400
2466
  if (!this.account.discord_token) { this.log('error', 'No token'); return; }
2401
2467
  if (!this.account.channel_id) { this.log('error', 'No channel'); return; }
2402
2468
 
2469
+ const LOGIN_TIMEOUT_MS = 30_000; // 30s max per account login
2470
+
2403
2471
  return new Promise((resolve) => {
2472
+ let resolved = false;
2473
+ const done = () => { if (!resolved) { resolved = true; resolve(); } };
2474
+
2475
+ // Timeout guard — don't let a single account hang the batch
2476
+ const timeoutId = setTimeout(() => {
2477
+ if (!resolved) {
2478
+ this.log('warn', 'Login timed out after 30s — will retry in background');
2479
+ done();
2480
+ // Retry login in background after timeout
2481
+ this._retryLoginBackground();
2482
+ }
2483
+ }, LOGIN_TIMEOUT_MS);
2484
+
2404
2485
  this.client.on('ready', async () => {
2486
+ clearTimeout(timeoutId);
2405
2487
  this.username = this.client.user.tag || this.username;
2406
2488
  this.avatarUrl = this.client.user.displayAvatarURL?.({ format: 'png', dynamic: true, size: 128 }) || null;
2407
- try {
2408
- await fetch(`${API_URL}/api/grinder/status`, {
2409
- method: 'POST',
2410
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2411
- body: JSON.stringify({ account_id: this.account.id, discord_username: this.username, avatar_url: this.avatarUrl }),
2412
- });
2413
- } catch { /* silent */ }
2489
+ // Report status non-blocking
2490
+ fetch(`${API_URL}/api/grinder/status`, {
2491
+ method: 'POST',
2492
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2493
+ body: JSON.stringify({ account_id: this.account.id, discord_username: this.username, avatar_url: this.avatarUrl }),
2494
+ }).catch(() => {});
2414
2495
 
2415
2496
  this.channel = await this.client.channels.fetch(this.account.channel_id).catch(() => null);
2416
2497
  if (!this.channel) {
2417
2498
  this.log('error', `Channel not found`);
2418
- resolve(); return;
2499
+ done(); return;
2419
2500
  }
2420
2501
 
2421
2502
  const enabledCmds = [
@@ -2438,49 +2519,108 @@ class AccountWorker {
2438
2519
  this.log('success', `#${chName} · ${enabledCmds.length} cmds`);
2439
2520
  this.setStatus('starting...');
2440
2521
 
2441
- // Load daily/weekly/monthly done state from Redis
2442
- if (redis) {
2443
- for (const cmd of ['daily', 'weekly', 'monthly', 'drops']) {
2444
- try {
2445
- const val = await redis.get(`dkg:done:${this.account.id}:${cmd}`);
2446
- if (val) {
2447
- const ttlSec = await redis.ttl(`dkg:done:${this.account.id}:${cmd}`);
2448
- if (ttlSec > 0) {
2449
- this.doneToday.set(cmd, Date.now() + ttlSec * 1000);
2450
- this.log('info', `${cmd} already claimed (${Math.round(ttlSec / 3600)}h remaining)`);
2451
- }
2452
- }
2453
- } catch {}
2522
+ // Load daily/weekly/monthly done state from Redis (non-blocking for login speed)
2523
+ this._loadRedisState().catch(() => {});
2524
+
2525
+ // Reduced settle time — 200ms is enough for most gateways
2526
+ await new Promise(r => setTimeout(r, 200));
2527
+ done();
2528
+ });
2529
+
2530
+ // Handle login errors so they don't hang
2531
+ this.client.on('error', (err) => {
2532
+ if (!resolved) {
2533
+ clearTimeout(timeoutId);
2534
+ const msg = (err?.message || '').toLowerCase();
2535
+ if (msg.includes('token_invalid') || msg.includes('invalid token') || msg.includes('401')) {
2536
+ this.log('error', `✗ TOKEN INVALID — this token is no longer valid`);
2537
+ this.paused = true;
2538
+ this._tokenInvalid = true;
2539
+ } else {
2540
+ this.log('error', `Login error: ${err?.message || err}`);
2454
2541
  }
2455
- // Load cached balance from Redis
2456
- try {
2457
- const balData = await redis.get(`dkg:bal:${this.account.id}`);
2458
- if (balData) {
2459
- const { wallet, bank } = JSON.parse(balData);
2460
- if (wallet > 0 || bank > 0) {
2461
- this.stats.balance = wallet;
2462
- this.stats.bankBalance = bank;
2463
- await fetch(`${API_URL}/api/grinder/status`, {
2464
- method: 'POST',
2465
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2466
- body: JSON.stringify({ account_id: this.account.id, balance: wallet, bank_balance: bank, total_balance: wallet + bank }),
2467
- }).catch(() => {});
2468
- }
2469
- }
2470
- } catch {}
2542
+ done();
2471
2543
  }
2472
-
2473
- // Let Discord gateway settle (reduced for faster startup)
2474
- await new Promise(r => setTimeout(r, 500));
2475
- resolve();
2476
2544
  });
2477
2545
 
2478
2546
  // Attach auto-recovery event listeners before login
2479
2547
  this._attachRecoveryListeners();
2480
- this.client.login(this.account.discord_token);
2548
+ this.client.login(this.account.discord_token).catch((err) => {
2549
+ clearTimeout(timeoutId);
2550
+ const msg = (err?.message || '').toLowerCase();
2551
+ if (msg.includes('token_invalid') || msg.includes('invalid token') || msg.includes('401') || msg.includes('incorrect login')) {
2552
+ this.log('error', `✗ TOKEN INVALID — check this account's token`);
2553
+ this.paused = true;
2554
+ this._tokenInvalid = true;
2555
+ // Report invalid status to API
2556
+ fetch(`${API_URL}/api/grinder/status`, {
2557
+ method: 'POST',
2558
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2559
+ body: JSON.stringify({ account_id: this.account.id, active: false, status: 'token_invalid' }),
2560
+ }).catch(() => {});
2561
+ sendWebhook('Token Invalid', `**${this.account.label || this.account.id}** has an invalid Discord token.`, 0xef4444);
2562
+ } else {
2563
+ this.log('error', `Login failed: ${err?.message || 'unknown error'}`);
2564
+ }
2565
+ done();
2566
+ });
2481
2567
  });
2482
2568
  }
2483
2569
 
2570
+ /** Load Redis cached state in background (non-blocking) */
2571
+ async _loadRedisState() {
2572
+ if (!redis) return;
2573
+ for (const cmd of ['daily', 'weekly', 'monthly', 'drops']) {
2574
+ try {
2575
+ const val = await redis.get(`dkg:done:${this.account.id}:${cmd}`);
2576
+ if (val) {
2577
+ const ttlSec = await redis.ttl(`dkg:done:${this.account.id}:${cmd}`);
2578
+ if (ttlSec > 0) {
2579
+ this.doneToday.set(cmd, Date.now() + ttlSec * 1000);
2580
+ this.log('info', `${cmd} already claimed (${Math.round(ttlSec / 3600)}h remaining)`);
2581
+ }
2582
+ }
2583
+ } catch {}
2584
+ }
2585
+ // Load cached balance from Redis
2586
+ try {
2587
+ const balData = await redis.get(`dkg:bal:${this.account.id}`);
2588
+ if (balData) {
2589
+ const { wallet, bank } = JSON.parse(balData);
2590
+ if (wallet > 0 || bank > 0) {
2591
+ this.stats.balance = wallet;
2592
+ this.stats.bankBalance = bank;
2593
+ await fetch(`${API_URL}/api/grinder/status`, {
2594
+ method: 'POST',
2595
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2596
+ body: JSON.stringify({ account_id: this.account.id, balance: wallet, bank_balance: bank, total_balance: wallet + bank }),
2597
+ }).catch(() => {});
2598
+ }
2599
+ }
2600
+ } catch {}
2601
+ }
2602
+
2603
+ /** Retry login in background after a timeout */
2604
+ async _retryLoginBackground() {
2605
+ await new Promise(r => setTimeout(r, 5000));
2606
+ if (this.client && !this.running) {
2607
+ this.log('info', 'Retrying login in background...');
2608
+ try {
2609
+ this.client.destroy();
2610
+ this.client = createLeanClient();
2611
+ this._attachRecoveryListeners();
2612
+ await this.client.login(this.account.discord_token);
2613
+ this.channel = await this.client.channels.fetch(this.account.channel_id).catch(() => null);
2614
+ if (this.channel) {
2615
+ this.username = this.client.user?.tag || this.username;
2616
+ this.log('success', `Background login OK`);
2617
+ }
2618
+ } catch (err) {
2619
+ this.log('error', `Background login failed: ${err?.message || err}`);
2620
+ }
2621
+ }
2622
+ }
2623
+
2484
2624
  stop() {
2485
2625
  this.running = false;
2486
2626
  this.paused = false;
@@ -2596,6 +2736,22 @@ async function start(apiKey, apiUrl) {
2596
2736
  console.log(` ${checks.join(' ')}`);
2597
2737
  console.log('');
2598
2738
 
2739
+ // ── Animated loading bar helper ──────────────────────────────
2740
+ const barW = Math.min(40, (process.stdout.columns || 80) - 30);
2741
+ let loginDone = 0;
2742
+ const drawLoginProgress = () => {
2743
+ const pct = accounts.length > 0 ? loginDone / accounts.length : 0;
2744
+ const filled = Math.round(pct * barW);
2745
+ const empty = barW - filled;
2746
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
2747
+ const bar = rgb(139, 92, 246) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(empty) + c.reset;
2748
+ const pctStr = `${Math.round(pct * 100)}%`;
2749
+ process.stdout.write(`\r ${rgb(139, 92, 246)}${spin}${c.reset} ${dim}Logging in...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${loginDone}${c.reset}${dim}/${c.reset}${c.white}${accounts.length}${c.reset} ${dim}${pctStr}${c.reset} `);
2750
+ };
2751
+
2752
+ // Progress animation timer
2753
+ const progressInterval = setInterval(drawLoginProgress, 80);
2754
+
2599
2755
  // Phase 1: Login all accounts (optimized for speed)
2600
2756
  const LOGIN_PROGRESS_EVERY = 10;
2601
2757
  // Reduced delays: 50-150ms between logins (faster startup for 1k+ accounts)
@@ -2610,38 +2766,80 @@ async function start(apiKey, apiUrl) {
2610
2766
  };
2611
2767
 
2612
2768
  // Parallel login in batches of 10 to avoid rate limits while being fast
2769
+ // Within each batch, stagger logins by 100-600ms to avoid gateway flood
2613
2770
  const BATCH_SIZE = 10;
2614
2771
  for (let i = 0; i < accounts.length; i += BATCH_SIZE) {
2615
2772
  if (shutdownCalled) break;
2616
2773
  const batch = accounts.slice(i, Math.min(i + BATCH_SIZE, accounts.length));
2617
2774
 
2618
- // Login batch in parallel
2775
+ // Staggered parallel login: fire each login with a small jitter delay
2619
2776
  await Promise.all(batch.map(async (acc, idx) => {
2777
+ // Stagger within batch: 0ms for first, 100-600ms for subsequent
2778
+ if (idx > 0) {
2779
+ const jitter = 100 + Math.floor(Math.random() * 500);
2780
+ await new Promise(r => setTimeout(r, jitter));
2781
+ }
2620
2782
  const worker = new AccountWorker(acc, i + idx);
2621
2783
  workers.push(worker);
2622
2784
  workerMap.set(acc.id, worker);
2623
2785
  await worker.start();
2786
+ loginDone++;
2624
2787
  }));
2625
2788
 
2626
2789
  // Small gap between batches
2627
2790
  if (i + BATCH_SIZE < accounts.length) {
2628
2791
  const gapMs = randomLoginGap();
2629
- log('info', `${c.dim}Logged in ${Math.min(i + BATCH_SIZE, accounts.length)}/${accounts.length}...${c.reset}`);
2630
2792
  await new Promise(r => setTimeout(r, gapMs));
2631
2793
  }
2632
2794
 
2633
2795
  hintGC();
2634
2796
  }
2635
2797
 
2636
- // Phase 2: Run inventory on ALL accounts in parallel (must complete before grinding)
2637
- log('info', `${c.dim}Checking inventory for ${workers.length} accounts...${c.reset}`);
2798
+ clearInterval(progressInterval);
2799
+ // Clear the progress line and show done
2800
+ process.stdout.write(`\r${c.clearLine}`);
2801
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Login complete${c.reset} ${rgb(52, 211, 153)}${loginDone}/${accounts.length}${c.reset} ${dim}accounts connected${c.reset}`);
2802
+ console.log('');
2803
+
2804
+ // Login summary: show invalid tokens clearly
2805
+ const invalidWorkers = workers.filter(w => w._tokenInvalid);
2806
+ const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
2807
+ if (invalidWorkers.length > 0) {
2808
+ console.log('');
2809
+ log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens:${c.reset}`);
2810
+ for (const w of invalidWorkers) {
2811
+ log('error', ` ✗ ${w.account.label || w.account.id} — token is invalid or expired`);
2812
+ }
2813
+ console.log('');
2814
+ }
2815
+ if (timedOutWorkers.length > 0) {
2816
+ log('warn', `${timedOutWorkers.length} account(s) timed out during login (will retry in background)`);
2817
+ }
2818
+
2819
+ // Filter out workers with invalid tokens from grinding
2820
+ const activeWorkers = workers.filter(w => !w._tokenInvalid);
2638
2821
 
2639
- // Parallel inventory checks with single-line progress
2822
+ // Phase 2: Run inventory on ALL valid accounts in parallel (must complete before grinding)
2823
+ console.log(` ${rgb(139, 92, 246)}${BRAILLE_SPIN[0]}${c.reset} ${dim}Checking inventory for ${c.reset}${c.bold}${activeWorkers.length}${c.reset}${dim} accounts...${c.reset}`);
2824
+
2825
+ // Animated inventory progress
2640
2826
  let invDone = 0;
2641
2827
  let invFailed = 0;
2642
- const total = workers.length;
2828
+ const total = activeWorkers.length;
2829
+ const invBarW = Math.min(40, (process.stdout.columns || 80) - 30);
2830
+
2831
+ const drawInvProgress = () => {
2832
+ const pct = total > 0 ? invDone / total : 0;
2833
+ const filled = Math.round(pct * invBarW);
2834
+ const empty = invBarW - filled;
2835
+ const spin = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
2836
+ const bar = rgb(34, 211, 238) + '█'.repeat(filled) + rgb(50, 50, 70) + '░'.repeat(empty) + c.reset;
2837
+ process.stdout.write(`\r ${rgb(34, 211, 238)}${spin}${c.reset} ${dim}Inventory...${c.reset} ${bar} ${c.bold}${rgb(52, 211, 153)}${invDone}${c.reset}${dim}/${c.reset}${c.white}${total}${c.reset} ${dim}${Math.round(pct * 100)}%${c.reset} `);
2838
+ };
2643
2839
 
2644
- await Promise.all(workers.map(async (w, i) => {
2840
+ const invProgressInterval = setInterval(drawInvProgress, 80);
2841
+
2842
+ await Promise.all(activeWorkers.map(async (w, i) => {
2645
2843
  try {
2646
2844
  const invRes = await w.checkInventory({
2647
2845
  force: true,
@@ -2656,19 +2854,23 @@ async function start(apiKey, apiUrl) {
2656
2854
  }
2657
2855
  }));
2658
2856
 
2659
- // Final summary
2660
- log('success', `Inventory: ${invDone}/${total} done${invFailed > 0 ? `, ${c.yellow}${invFailed} failed${c.reset}` : ''}`);
2857
+ clearInterval(invProgressInterval);
2858
+ process.stdout.write(`\r${c.clearLine}`);
2661
2859
 
2860
+ // Final summary
2662
2861
  if (invFailed > 0) {
2663
- log('error', `${c.red}Inventory phase incomplete: ${invDone}/${workers.length} complete, ${invFailed} failed/incomplete. Not starting grind loops.${c.reset}`);
2862
+ console.log(` ${rgb(239, 68, 68)}✗${c.reset} ${c.bold}Inventory incomplete${c.reset} ${rgb(52, 211, 153)}${invDone}${c.reset}${dim}/${c.reset}${c.white}${total}${c.reset} done, ${rgb(239, 68, 68)}${invFailed} failed${c.reset}`);
2863
+ log('error', `${c.red}Not starting grind loops — ${invFailed} accounts failed inventory.${c.reset}`);
2664
2864
  return;
2665
2865
  }
2666
2866
 
2667
- const invSummaryColor = invFailed > 0 ? c.yellow : c.green;
2668
- log('success', `${c.dim}Inventory phase complete: ${invSummaryColor}${invDone}/${workers.length}${c.reset}${c.dim} done${invFailed > 0 ? `, ${c.yellow}${invFailed} failed${c.reset}${c.dim}` : ''}. Starting grind loops...${c.reset}`);
2867
+ console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}Inventory complete${c.reset} ${rgb(52, 211, 153)}${invDone}/${total}${c.reset} ${dim}all clear${c.reset}`);
2868
+ console.log('');
2869
+ console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);
2870
+ console.log('');
2669
2871
 
2670
- // Phase 3: Start all grind loops
2671
- for (const w of workers) {
2872
+ // Phase 3: Start all grind loops (only for valid workers)
2873
+ for (const w of activeWorkers) {
2672
2874
  if (!shutdownCalled) w.grindLoop();
2673
2875
  }
2674
2876
 
@@ -2682,6 +2884,13 @@ async function start(apiKey, apiUrl) {
2682
2884
  process.stdout.write(c.hide);
2683
2885
  dashboardLines = 0;
2684
2886
 
2887
+ // Re-render on terminal resize so layout adapts to window size
2888
+ process.stdout.on('resize', () => {
2889
+ process.stdout.write('\x1b[2J\x1b[H');
2890
+ dashboardLines = 0;
2891
+ scheduleRender();
2892
+ });
2893
+
2685
2894
  setInterval(() => scheduleRender(), 1000);
2686
2895
  scheduleRender();
2687
2896
 
@@ -2830,10 +3039,17 @@ function setupKeyboardShortcuts() {
2830
3039
  process.stdin.resume();
2831
3040
  process.stdin.setEncoding('utf8');
2832
3041
 
2833
- // Modern styled keyboard shortcuts with box drawing
2834
- console.log(`\n ${rgb(139, 92, 246)}╭${c.reset}${c.dim}${'─'.repeat(50)}${c.reset}${rgb(139, 92, 246)}╮${c.reset}`);
2835
- console.log(` ${rgb(139, 92, 246)}│${c.reset} ${c.bold}Shortcuts:${c.reset} ${c.dim}p${c.reset}=pause ${c.dim}r${c.reset}=resume ${c.dim}s${c.reset}=status ${c.dim}q${c.reset}=quit ${c.dim}?${c.reset}=help ${rgb(139, 92, 246)}│${c.reset}`);
2836
- console.log(` ${rgb(139, 92, 246)}╰${c.reset}${c.dim}${'─'.repeat(50)}${c.reset}${rgb(139, 92, 246)}╯${c.reset}`);
3042
+ // Premium styled keyboard shortcuts with gradient box
3043
+ const accent = rgb(139, 92, 246);
3044
+ const dim = c.dim;
3045
+ const kw = 60;
3046
+ console.log('');
3047
+ console.log(` ${accent}╭${'─'.repeat(kw)}╮${c.reset}`);
3048
+ console.log(` ${accent}│${c.reset} ${gradientText('KEYBOARD SHORTCUTS', [192, 132, 252], [52, 211, 153])}${' '.repeat(kw - 20)}${accent}│${c.reset}`);
3049
+ console.log(` ${accent}├${'─'.repeat(kw)}┤${c.reset}`);
3050
+ 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}`);
3051
+ console.log(` ${accent}╰${'─'.repeat(kw)}╯${c.reset}`);
3052
+ console.log('');
2837
3053
 
2838
3054
  process.stdin.on('data', (key) => {
2839
3055
  const k = key.toString().toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "6.1.0",
3
+ "version": "6.5.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"