vg-coder-cli 2.0.49 → 2.0.50

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/INTEGRATION.md CHANGED
@@ -29,6 +29,7 @@ External Service ◀── POST <webhookUrl> (push, retry 3×)
29
29
  - **Workers**: mỗi tab Chrome trên `aistudio.google.com` với extension VG Coder = 1 worker. Server tự load-balance song song giữa các worker idle.
30
30
  - **Launchers**: mỗi Chrome profile = 1 launcher (background service worker của extension). Dùng để mở/đóng tab AI Studio và lock model cho từng task.
31
31
  - **Auto-recycle**: sau mỗi task done/failed, server gọi launcher đóng tab worker đó + mở lại tab mới với `?model=<target>` — guarantee model lock per task. Mark worker `recycling` để tránh race.
32
+ - **Idle tab TTL** (v2.0.50+): sau khi recycle xong, nếu không có task mới trong `VG_WORKER_IDLE_TTL_MS` (default `120000` = 2 phút), launcher đóng tab → CPU container về ~0%. Task tiếp theo sẽ trigger `launcher:open_aistudio` để mở lại tab. Set `VG_WORKER_IDLE_TTL_MS=0` để giữ tab always-on (behavior cũ); set `=1` để đóng ngay sau mỗi task.
32
33
  - **Failover**: rate-limit / quota error → mark worker `rate_limited` 30 phút, requeue task sang worker khác (max 3 attempts). Tab giữ nguyên qua cooldown (không recycle khi rate-limited).
33
34
  - **Default model**: `gemini-3-flash-preview` (free + multimodal — verified working với image + PDF). Avoid `-lite` (text-only) hoặc `-pro-preview` (paid alias to Deep Research).
34
35
  - **Persistence**: tasks lưu vào `.vg/tasks/<id>/{task.json,result.md,files/}` của active project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vg-coder-cli",
3
- "version": "2.0.49",
3
+ "version": "2.0.50",
4
4
  "description": "🚀 CLI tool to analyze projects, concatenate source files, count tokens, and export HTML with syntax highlighting and copy functionality",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,6 +12,18 @@ const COOLDOWN_TICK_MS = 60_000;
12
12
  const MAX_TASK_ATTEMPTS = 3;
13
13
  const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
14
14
 
15
+ // Idle worker tab TTL: close the AI Studio tab if no new task arrives within
16
+ // this window after a task completes (and the post-task recycle finishes).
17
+ // Set 0 to disable (worker tab always-on, behavior pre-2.0.50).
18
+ // Set 1 to close immediately after every task. Default 120000 (2 minutes) keeps
19
+ // burst tasks fast while idling background CPU to ~0% on long quiet stretches.
20
+ const WORKER_IDLE_TTL_MS = (() => {
21
+ const raw = process.env.VG_WORKER_IDLE_TTL_MS;
22
+ if (raw === undefined || raw === '') return 120_000;
23
+ const n = parseInt(raw, 10);
24
+ return Number.isFinite(n) && n >= 0 ? n : 120_000;
25
+ })();
26
+
15
27
  class TaskQueue {
16
28
  constructor() {
17
29
  this.io = null;
@@ -22,6 +34,10 @@ class TaskQueue {
22
34
  this.cache = new Map(); // taskId → task
23
35
  this.launchedAt = 0;
24
36
  this._cooldownTimer = null;
37
+ // workerEmail → setTimeout handle. When timer fires, close that email's
38
+ // AI Studio tab (free CPU). Cancelled when a new task arrives or a worker
39
+ // recycles for that email.
40
+ this._idleCloseTimers = new Map();
25
41
  }
26
42
 
27
43
  attachIO(io) {
@@ -127,6 +143,27 @@ class TaskQueue {
127
143
  ? String(meta.email).toLowerCase()
128
144
  : `unknown:${socket.id}`;
129
145
 
146
+ // If the same socket re-registers (e.g. extension scraped the email after
147
+ // initial register completed), MERGE into the existing entry instead of
148
+ // replacing it. Replacing would clobber currentTaskId / status while a
149
+ // task was mid-flight and lose the only path back to onWorkerCompleted.
150
+ const existing = this.workers.get(socket.id);
151
+ if (existing) {
152
+ const wasUnknown = (existing.email || '').startsWith('unknown:');
153
+ existing.email = email;
154
+ existing.meta = { ...existing.meta, ...(meta || {}) };
155
+ existing.lastSeen = Date.now();
156
+ if (meta?.chromeId && email && !email.startsWith('unknown:')) {
157
+ this._bindChromeIdToEmail(meta.chromeId, email);
158
+ }
159
+ if (wasUnknown && email !== existing.email) {
160
+ // shouldn't happen — kept for clarity (email already assigned above)
161
+ }
162
+ console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})`));
163
+ setImmediate(() => this._drain());
164
+ return true;
165
+ }
166
+
130
167
  const reg = {
131
168
  socket,
132
169
  id: socket.id,
@@ -276,6 +313,12 @@ class TaskQueue {
276
313
  this.workers.delete(socket.id);
277
314
  console.log(chalk.yellow(`[TaskQueue] Worker disconnected: ${socket.id} (${worker.email})`));
278
315
 
316
+ // Drop pending idle-close timer if no other worker for this email remains.
317
+ if (worker.email && !worker.email.startsWith('unknown:')) {
318
+ const stillPresent = [...this.workers.values()].some(w => w.email === worker.email);
319
+ if (!stillPresent) this._cancelIdleClose(worker.email);
320
+ }
321
+
279
322
  // If a task was running on this worker, fail it (with possible failover).
280
323
  if (worker.currentTaskId) {
281
324
  const task = this.cache.get(worker.currentTaskId);
@@ -298,6 +341,9 @@ class TaskQueue {
298
341
  this.cache.set(task.id, task);
299
342
  this.pending.push({ id: task.id, workingDir: task.workingDir });
300
343
 
344
+ // Cancel any pending idle-close so the about-to-dispatch tab stays open.
345
+ this._cancelAllIdleCloseForLaunch(task);
346
+
301
347
  // Auto-launch if no worker matches the task's pin (or no workers at all).
302
348
  if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
303
349
 
@@ -417,6 +463,10 @@ class TaskQueue {
417
463
  */
418
464
  _recycleWorkerTab(workerEmail) {
419
465
  if (!workerEmail || workerEmail.startsWith('unknown:')) return;
466
+ // Cancel any pending idle-close — we're about to recycle, no need to close
467
+ // separately. (This handles the race where a task lands during the idle
468
+ // window: drain triggers recycle, idle timer becomes redundant.)
469
+ this._cancelIdleClose(workerEmail);
420
470
  // Mark every worker matching this email as 'recycling' so _drain skips
421
471
  // them while the close+open is in flight. Without this, a queued task
422
472
  // can race in and get dispatched to a worker whose tab is about to
@@ -424,12 +474,22 @@ class TaskQueue {
424
474
  for (const w of this.workers.values()) {
425
475
  if (w.email === workerEmail && w.status === 'idle') w.status = 'recycling';
426
476
  }
477
+ // If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
478
+ // skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
479
+ const closeOnly = WORKER_IDLE_TTL_MS === 1;
427
480
  setImmediate(async () => {
428
481
  try {
429
482
  await this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000);
483
+ if (closeOnly) {
484
+ console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
485
+ return;
486
+ }
430
487
  await new Promise(r => setTimeout(r, 400));
431
488
  await this.requestLauncher('launcher:open_tab', {}, { workerLabel: workerEmail }, 8_000);
432
489
  console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}`));
490
+ // Schedule idle close. If new task arrives before TTL, enqueue() will
491
+ // cancel this timer.
492
+ this._scheduleIdleClose(workerEmail);
433
493
  } catch (err) {
434
494
  console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
435
495
  // Restore status if recycle failed and worker still in registry.
@@ -441,6 +501,62 @@ class TaskQueue {
441
501
  });
442
502
  }
443
503
 
504
+ // ---------- Idle worker tab TTL ----------
505
+
506
+ /**
507
+ * Schedule an automatic tab-close for a worker email after WORKER_IDLE_TTL_MS
508
+ * of idleness. Only kicks in when there's no pending task that can land on
509
+ * this worker. Re-arming (e.g. after another recycle) cancels the previous
510
+ * timer.
511
+ */
512
+ _scheduleIdleClose(workerEmail) {
513
+ if (!workerEmail || workerEmail.startsWith('unknown:')) return;
514
+ if (!WORKER_IDLE_TTL_MS) return; // 0 → feature disabled
515
+ if (WORKER_IDLE_TTL_MS === 1) return; // 1 → handled inline by _recycleWorkerTab
516
+ this._cancelIdleClose(workerEmail);
517
+ const t = setTimeout(() => {
518
+ this._idleCloseTimers.delete(workerEmail);
519
+ // Skip if a task is already queued for this worker (or workerless).
520
+ const hasPendingForEmail = this.pending.some(p => {
521
+ const task = this.cache.get(p.id);
522
+ if (!task) return false;
523
+ if (!task.workerLabel) return true;
524
+ return task.workerLabel.toLowerCase() === workerEmail;
525
+ });
526
+ if (hasPendingForEmail) return;
527
+ // Skip if any worker for this email is busy/recycling (in-flight work).
528
+ for (const w of this.workers.values()) {
529
+ if (w.email === workerEmail && w.status !== 'idle') return;
530
+ }
531
+ console.log(chalk.gray(`[TaskQueue] Idle TTL expired for ${workerEmail} — closing tab`));
532
+ this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000)
533
+ .catch(err => console.log(chalk.yellow(`[TaskQueue] Idle close ${workerEmail} failed: ${err.message}`)));
534
+ }, WORKER_IDLE_TTL_MS);
535
+ if (t.unref) t.unref();
536
+ this._idleCloseTimers.set(workerEmail, t);
537
+ }
538
+
539
+ _cancelIdleClose(workerEmail) {
540
+ const t = this._idleCloseTimers.get(workerEmail);
541
+ if (t) {
542
+ clearTimeout(t);
543
+ this._idleCloseTimers.delete(workerEmail);
544
+ }
545
+ }
546
+
547
+ _cancelAllIdleCloseForLaunch(task) {
548
+ // When a task is enqueued, cancel any idle-close timers that could
549
+ // otherwise close a tab we're about to dispatch to.
550
+ if (!task) return;
551
+ if (task.workerLabel) {
552
+ this._cancelIdleClose(task.workerLabel.toLowerCase());
553
+ } else {
554
+ for (const email of Array.from(this._idleCloseTimers.keys())) {
555
+ this._cancelIdleClose(email);
556
+ }
557
+ }
558
+ }
559
+
444
560
  // ---------- Internal ----------
445
561
 
446
562
  _workerByCurrentTask(taskId) {
@@ -531,10 +647,15 @@ class TaskQueue {
531
647
  return;
532
648
  }
533
649
 
534
- // Snapshot workers with status idle
535
- let idleWorkers = [...this.workers.values()].filter(w => w.status === 'idle');
650
+ // Snapshot workers with status idle. Skip workers whose email scrape
651
+ // is still pending (`unknown:<sid>`) extension typically re-registers
652
+ // ~3-5s later with the real email, and dispatching during that window
653
+ // races with the re-register and clobbers task lifecycle state.
654
+ let idleWorkers = [...this.workers.values()].filter(w =>
655
+ w.status === 'idle' && !(w.email || '').startsWith('unknown:')
656
+ );
536
657
  if (!idleWorkers.length && this.pending.length) {
537
- // No idle workers — nothing to dispatch right now.
658
+ // No usable idle workers — nothing to dispatch right now.
538
659
  return;
539
660
  }
540
661