vg-coder-cli 2.0.67 → 2.0.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vg-coder-cli",
3
- "version": "2.0.67",
3
+ "version": "2.0.69",
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": {
@@ -99,7 +99,12 @@ class ApiServer {
99
99
 
100
100
  // Task worker registration & lifecycle
101
101
  socket.on('worker:register', (meta) => { taskQueue.setWorker(socket, meta || {}); });
102
- socket.on('worker:heartbeat', () => { /* keep-alive only */ });
102
+ socket.on('worker:heartbeat', () => {
103
+ // Bump lastSeen so the stale-worker reaper doesn't drop a live
104
+ // socket that's just been quiet (no task in flight).
105
+ const w = taskQueue.workers.get(socket.id);
106
+ if (w) w.lastSeen = Date.now();
107
+ });
103
108
  socket.on('task:complete', (payload) => {
104
109
  if (payload?.taskId) taskQueue.onWorkerComplete(payload.taskId, payload, socket.id);
105
110
  });
@@ -799,6 +804,31 @@ class ApiServer {
799
804
  } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
800
805
  });
801
806
 
807
+ // Block until the pinned launcher is ready for tasks. Replaces the common
808
+ // open-tab → poll-launchers → poll-workers pattern with a single sync
809
+ // call. Body: { chromeId, timeoutMs?: 15000, requireEmail?: true,
810
+ // requireWorker?: true }
811
+ // - 200 { ok: true, launcher, worker, elapsedMs } when ready
812
+ // - 404 launcher_not_found if chromeId never matched a launcher
813
+ // - 504 wait_ready_timeout if the readiness condition didn't hold in time
814
+ this.app.post('/api/launcher/wait-ready', async (req, res) => {
815
+ try {
816
+ const body = req.body || {};
817
+ const chromeId = (body.chromeId || '').toString().trim();
818
+ if (!chromeId) return res.status(400).json({ error: 'chromeId required' });
819
+ const result = await taskQueue.waitReady(chromeId, {
820
+ timeoutMs: body.timeoutMs,
821
+ requireEmail: body.requireEmail,
822
+ requireWorker: body.requireWorker,
823
+ });
824
+ res.json(result);
825
+ } catch (e) {
826
+ if (e.code === 'launcher_not_found') return res.status(404).json({ error: 'launcher_not_found' });
827
+ if (e.code === 'wait_ready_timeout') return res.status(504).json({ error: 'wait_ready_timeout', details: e.details });
828
+ res.status(500).json({ error: e.message });
829
+ }
830
+ });
831
+
802
832
  // Run a predefined chrome.* command inside the launcher SW. Manifest V3
803
833
  // CSP forbids new Function() in service workers, so eval was replaced by
804
834
  // a fixed command map. Body:
@@ -9,6 +9,13 @@ const AUTO_LAUNCH_DEBOUNCE_MS = 30_000;
9
9
  const INTER_TASK_GAP_MS = 2_500;
10
10
  const RATE_LIMIT_COOLDOWN_MS = 30 * 60_000;
11
11
  const COOLDOWN_TICK_MS = 60_000;
12
+ // Reap workers whose socket went silent: no heartbeat AND no task activity
13
+ // past this window. Heartbeat fires every 5s from task-worker.js, so 120s
14
+ // is ~24 missed beats — comfortably past any legitimate network hiccup but
15
+ // short enough that zombie sockets (Chrome tab closed without disconnect
16
+ // event reaching server, content-script paused by power-saver, etc.) don't
17
+ // linger forever and bloat _drain's idleWorkers snapshot.
18
+ const STALE_WORKER_TTL_MS = 120_000;
12
19
  const MAX_TASK_ATTEMPTS = 3;
13
20
  const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
14
21
 
@@ -118,6 +125,7 @@ class TaskQueue {
118
125
  id: w.id,
119
126
  email: w.email,
120
127
  chromeId: w.chromeId || null,
128
+ tabId: typeof w.tabId === 'number' ? w.tabId : null,
121
129
  status: w.status,
122
130
  currentTaskId: w.currentTaskId,
123
131
  cooldownUntil: cd,
@@ -173,6 +181,10 @@ class TaskQueue {
173
181
  // Phase 1: chromeId comes from worker meta if extension passes it,
174
182
  // else reverse-lookup from chromeIdToEmail. Phase 2 will require it.
175
183
  existing.chromeId = meta?.chromeId || this._emailToChromeId(email) || existing.chromeId || null;
184
+ // tabId enables surgical close on recycle (only this tab vs all
185
+ // AI Studio tabs in the profile). Older extension versions may not
186
+ // send it — fall back to the previously stored value or null.
187
+ if (typeof meta?.tabId === 'number') existing.tabId = meta.tabId;
176
188
  if (wasUnknown && email !== existing.email) {
177
189
  // shouldn't happen — kept for clarity (email already assigned above)
178
190
  }
@@ -195,6 +207,9 @@ class TaskQueue {
195
207
  // Reverse-lookup from chromeIdToEmail fills the gap for already-bound
196
208
  // profiles. Recycle/dispatch paths prefer chromeId when present.
197
209
  chromeId: meta?.chromeId || this._emailToChromeId(email) || null,
210
+ // tabId from extension. When known, recycle closes only this tab —
211
+ // when null, recycle falls back to close-all-AI-Studio-tabs in profile.
212
+ tabId: typeof meta?.tabId === 'number' ? meta.tabId : null,
198
213
  meta: { ...(meta || {}), registeredAt: Date.now() },
199
214
  status: 'idle',
200
215
  currentTaskId: null,
@@ -269,6 +284,62 @@ class TaskQueue {
269
284
  }
270
285
  }
271
286
 
287
+ /**
288
+ * Poll until a launcher's profile is "ready" for tasks: launcher connected,
289
+ * email scraped (if requireEmail), and ≥1 idle worker registered for the
290
+ * chromeId (if requireWorker). Saves callers from running their own
291
+ * GET /api/launchers + /api/workers poll loop after open-tab.
292
+ *
293
+ * Returns a snapshot on success; throws on timeout or immediate-miss.
294
+ */
295
+ async waitReady(chromeId, opts = {}) {
296
+ const timeoutMs = Math.min(Math.max(opts.timeoutMs | 0 || 15_000, 1_000), 60_000);
297
+ const requireEmail = opts.requireEmail !== false;
298
+ const requireWorker = opts.requireWorker !== false;
299
+ const pollMs = 250;
300
+ const deadline = Date.now() + timeoutMs;
301
+
302
+ // Immediate-miss: chromeId never matched a launcher → 404 contract
303
+ // (vs. timeout). Caller can map differently.
304
+ const hasLauncher = () => [...this.launchers.values()].some(l => l.chromeId === chromeId);
305
+ if (!hasLauncher()) {
306
+ const err = new Error('launcher_not_found');
307
+ err.code = 'launcher_not_found';
308
+ throw err;
309
+ }
310
+
311
+ while (Date.now() < deadline) {
312
+ const launcher = [...this.launchers.values()].find(l => l.chromeId === chromeId);
313
+ if (launcher) {
314
+ const emailOk = !requireEmail || (launcher.email && !launcher.email.startsWith('unknown:'));
315
+ const worker = !requireWorker ? null : [...this.workers.values()].find(w =>
316
+ w.chromeId === chromeId
317
+ && w.status === 'idle'
318
+ && w.email && !w.email.startsWith('unknown:')
319
+ );
320
+ const workerOk = !requireWorker || !!worker;
321
+ if (emailOk && workerOk) {
322
+ return {
323
+ ok: true,
324
+ launcher: { id: launcher.id, chromeId: launcher.chromeId, email: launcher.email },
325
+ worker: worker ? { id: worker.id, email: worker.email, status: worker.status } : null,
326
+ elapsedMs: timeoutMs - (deadline - Date.now()),
327
+ };
328
+ }
329
+ } else {
330
+ // Launcher disconnected during the wait — surface as not_found, not timeout.
331
+ const err = new Error('launcher_not_found');
332
+ err.code = 'launcher_not_found';
333
+ throw err;
334
+ }
335
+ await new Promise(r => setTimeout(r, pollMs));
336
+ }
337
+ const err = new Error('wait_ready_timeout');
338
+ err.code = 'wait_ready_timeout';
339
+ err.details = { chromeId, requireEmail, requireWorker, timeoutMs };
340
+ throw err;
341
+ }
342
+
272
343
  // Reverse lookup: find chromeId bound to email. Returns the first match —
273
344
  // if two launchers login the same email (test vs prod), this is ambiguous
274
345
  // and the caller should prefer explicit chromeId routing instead.
@@ -479,13 +550,14 @@ class TaskQueue {
479
550
  }
480
551
  const workerEmail = worker.email;
481
552
  const workerChromeId = worker.chromeId || null;
553
+ const workerTabId = typeof worker.tabId === 'number' ? worker.tabId : null;
482
554
  worker.currentTaskId = null;
483
555
  worker.status = 'idle';
484
556
  worker.lastSeen = Date.now();
485
557
 
486
558
  if (task.cancelRequestedAt) {
487
559
  // discard result; still recycle the tab so next task gets a clean slate
488
- this._recycleWorkerTab(workerEmail, workerChromeId);
560
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
489
561
  setImmediate(() => this._drain());
490
562
  return;
491
563
  }
@@ -506,7 +578,7 @@ class TaskQueue {
506
578
  // model URL pinned. AI Studio's "+ New chat" link drops the ?model=
507
579
  // query and Angular routerLink ignores href hijack — close+reopen via
508
580
  // launcher is the only deterministic way to lock the model per task.
509
- this._recycleWorkerTab(workerEmail, workerChromeId);
581
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
510
582
  // Drain right away so other idle workers pick up queued tasks while
511
583
  // this worker's tab is being recycled (~5s).
512
584
  setImmediate(() => this._drain());
@@ -521,30 +593,32 @@ class TaskQueue {
521
593
  }
522
594
  const workerEmail = worker.email;
523
595
  const workerChromeId = worker.chromeId || null;
596
+ const workerTabId = typeof worker.tabId === 'number' ? worker.tabId : null;
524
597
  await this._failOrRequeue(task, worker, error);
525
598
  // Only recycle if the worker isn't in cooldown (rate-limited workers
526
599
  // have their tab still open intentionally — recycling won't unfreeze
527
600
  // the quota and just wastes a tab cycle).
528
601
  const w = this.workers.get(socketId);
529
602
  if (!w || w.status !== 'rate_limited') {
530
- this._recycleWorkerTab(workerEmail, workerChromeId);
603
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
531
604
  } else {
532
605
  setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
533
606
  }
534
607
  }
535
608
 
536
609
  /**
537
- * Ask the launcher in the given profile to close all AI Studio tabs and
538
- * open a fresh one. The browser-side close fires worker:disconnect
539
- * clearWorker; the open creates a new tab whose worker registers ~3-5s
540
- * later. _drain runs naturally on register so any queued task picks up
541
- * the fresh worker. Fire-and-forget — errors are logged but don't bubble.
610
+ * Recycle the worker's tab: close it, then (unless close-only TTL) reopen
611
+ * a fresh one with the pinned model. Fire-and-forget errors are logged
612
+ * but don't bubble.
542
613
  *
543
- * Prefers chromeId routing when provided (exact profile) — falls back to
544
- * workerEmail when chromeId is unknown (e.g. extension hasn't propagated
545
- * chromeId yet in Phase 1).
614
+ * Routing precedence:
615
+ * - chromeId pins the exact Chrome profile (falls back to email if absent)
616
+ * - tabId pins the exact AI Studio tab in that profile, so user-opened
617
+ * tabs in the same profile are NOT closed alongside the worker tab.
618
+ * When tabId is null (older extension version), close-all behavior is
619
+ * used as before — backward compatible.
546
620
  */
547
- _recycleWorkerTab(workerEmail, chromeId = null) {
621
+ _recycleWorkerTab(workerEmail, chromeId = null, tabId = null) {
548
622
  if (!workerEmail || workerEmail.startsWith('unknown:')) return;
549
623
  // Cancel any pending idle-close — we're about to recycle, no need to close
550
624
  // separately. (This handles the race where a task lands during the idle
@@ -552,22 +626,28 @@ class TaskQueue {
552
626
  this._cancelIdleClose(workerEmail);
553
627
  // Mark workers in this profile as 'recycling' so _drain skips them. Match
554
628
  // by chromeId when present (exact profile), else by email (covers the
555
- // chromeId-unknown path).
629
+ // chromeId-unknown path). When tabId is known, only mark the worker for
630
+ // that specific tab — other workers in the same profile (other tabs)
631
+ // stay idle and can keep accepting tasks.
556
632
  for (const w of this.workers.values()) {
557
- const matches = chromeId
558
- ? w.chromeId === chromeId
559
- : w.email === workerEmail;
633
+ let matches;
634
+ if (tabId != null) matches = w.tabId === tabId;
635
+ else if (chromeId) matches = w.chromeId === chromeId;
636
+ else matches = w.email === workerEmail;
560
637
  if (matches && w.status === 'idle') w.status = 'recycling';
561
638
  }
562
639
  // Route launcher calls by chromeId when known, email otherwise.
563
640
  const routeOpts = chromeId ? { chromeId } : { workerLabel: workerEmail };
564
641
  const pinKey = chromeId || workerEmail;
642
+ // Surgical close: pass tabId so the launcher closes only the worker's
643
+ // tab. Without tabId, launcher falls back to close-all (legacy behavior).
644
+ const closePayload = tabId != null ? { tabId } : {};
565
645
  // If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
566
646
  // skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
567
647
  const closeOnly = WORKER_IDLE_TTL_MS === 1;
568
648
  setImmediate(async () => {
569
649
  try {
570
- await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
650
+ await this.requestLauncher('launcher:close_tab', closePayload, routeOpts, 5_000);
571
651
  if (closeOnly) {
572
652
  console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
573
653
  return;
@@ -631,9 +711,35 @@ class TaskQueue {
631
711
  for (const w of this.workers.values()) {
632
712
  if (w.email === workerEmail && w.status !== 'idle') return;
633
713
  }
634
- console.log(chalk.gray(`[TaskQueue] Idle TTL expired for ${workerEmail} closing tab`));
635
- this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000)
636
- .catch(err => console.log(chalk.yellow(`[TaskQueue] Idle close ${workerEmail} failed: ${err.message}`)));
714
+ // Surgical close: collect tabIds of all idle workers in this email
715
+ // and close them one-by-one so user-opened tabs in the same profile
716
+ // survive. If a worker has no tabId (older extension), fall back to
717
+ // close-all-by-email for that worker.
718
+ const tabIdsByLauncher = new Map(); // routeOpts JSON → [tabId, ...]
719
+ let needCloseAll = false;
720
+ for (const w of this.workers.values()) {
721
+ if (w.email !== workerEmail) continue;
722
+ if (w.status !== 'idle') continue;
723
+ const route = w.chromeId ? { chromeId: w.chromeId } : { workerLabel: workerEmail };
724
+ const key = JSON.stringify(route);
725
+ if (typeof w.tabId === 'number') {
726
+ if (!tabIdsByLauncher.has(key)) tabIdsByLauncher.set(key, { route, tabIds: [] });
727
+ tabIdsByLauncher.get(key).tabIds.push(w.tabId);
728
+ } else {
729
+ needCloseAll = true;
730
+ }
731
+ }
732
+ console.log(chalk.gray(`[TaskQueue] Idle TTL expired for ${workerEmail} — closing tab(s)`));
733
+ if (needCloseAll) {
734
+ this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000)
735
+ .catch(err => console.log(chalk.yellow(`[TaskQueue] Idle close ${workerEmail} failed: ${err.message}`)));
736
+ }
737
+ for (const { route, tabIds } of tabIdsByLauncher.values()) {
738
+ for (const tabId of tabIds) {
739
+ this.requestLauncher('launcher:close_tab', { tabId }, route, 5_000)
740
+ .catch(err => console.log(chalk.yellow(`[TaskQueue] Idle close tab ${tabId} failed: ${err.message}`)));
741
+ }
742
+ }
637
743
  }, WORKER_IDLE_TTL_MS);
638
744
  if (t.unref) t.unref();
639
745
  this._idleCloseTimers.set(workerEmail, t);
@@ -873,9 +979,33 @@ class TaskQueue {
873
979
  console.log(chalk.green(`[TaskQueue] Worker ${w.email} cooldown ended`));
874
980
  }
875
981
  }
982
+ this._reapStaleWorkers(now);
876
983
  if (revived) setImmediate(() => this._drain());
877
984
  }
878
985
 
986
+ // Drop workers whose socket has been silent past STALE_WORKER_TTL_MS. Skips
987
+ // busy/recycling workers — those are mid-task or mid-recycle and may
988
+ // legitimately not heartbeat until they emit task:complete. Reaping a
989
+ // zombie worker also triggers _drain because the removal frees a slot
990
+ // that the legitimate launcher can refill when it reconnects.
991
+ _reapStaleWorkers(now = Date.now()) {
992
+ let reaped = 0;
993
+ for (const w of [...this.workers.values()]) {
994
+ if (w.status === 'busy' || w.status === 'recycling') continue;
995
+ const idleFor = now - (w.lastSeen || 0);
996
+ if (idleFor <= STALE_WORKER_TTL_MS) continue;
997
+ console.log(chalk.yellow(
998
+ `[TaskQueue] Reaping stale worker ${w.id} (${w.email || 'unknown'}, idle ${Math.round(idleFor / 1000)}s)`
999
+ ));
1000
+ // Drop from registry. Don't try to disconnect the socket — if it's
1001
+ // genuinely alive, _drain will skip dispatching to it, and the next
1002
+ // worker:heartbeat or worker:register from that socket re-adds it.
1003
+ this.workers.delete(w.id);
1004
+ reaped++;
1005
+ }
1006
+ if (reaped) setImmediate(() => this._drain());
1007
+ }
1008
+
879
1009
  _autoLaunchBrowser(task = null) {
880
1010
  const now = Date.now();
881
1011
  if (now - this.launchedAt < AUTO_LAUNCH_DEBOUNCE_MS) return;
@@ -369,6 +369,10 @@ function connect() {
369
369
  // Register with whatever email we have right now (may be null on cold load).
370
370
  const initialEmail = extractEmail();
371
371
  const chromeId = (window.vetgo && window.vetgo.chromeId) || null;
372
+ // tabId injected by background.ts during CONTROLLER script load.
373
+ // Server uses it for surgical tab close in _recycleWorkerTab so user
374
+ // tabs in the same profile don't get closed alongside the worker tab.
375
+ const tabId = (window.vetgo && typeof window.vetgo.tabId === 'number') ? window.vetgo.tabId : null;
372
376
  // pinnedModel: lấy từ URL ?model=X — server dùng để re-establish pin
373
377
  // sau khi worker register với email thật (không lúc open-tab vì lúc đó
374
378
  // launcher có thể chưa scrape email).
@@ -382,9 +386,10 @@ function connect() {
382
386
  userAgent: navigator.userAgent,
383
387
  email: initialEmail,
384
388
  chromeId,
389
+ tabId,
385
390
  pinnedModel
386
391
  });
387
- console.log(`[TaskWorker] Initial email: ${initialEmail || '(pending)'}, chromeId: ${chromeId || '(none)'}`);
392
+ console.log(`[TaskWorker] Initial email: ${initialEmail || '(pending)'}, chromeId: ${chromeId || '(none)'}, tabId: ${tabId || '(none)'}`);
388
393
 
389
394
  if (heartbeatTimer) clearInterval(heartbeatTimer);
390
395
  heartbeatTimer = setInterval(() => {
@@ -408,6 +413,7 @@ function connect() {
408
413
  userAgent: navigator.userAgent,
409
414
  email: resolved,
410
415
  chromeId: (window.vetgo && window.vetgo.chromeId) || null,
416
+ tabId: (window.vetgo && typeof window.vetgo.tabId === 'number') ? window.vetgo.tabId : null,
411
417
  pinnedModel: pm
412
418
  });
413
419
  console.log(`[TaskWorker] Re-registered with email: ${resolved} (pinnedModel=${pm || 'none'})`);