vg-coder-cli 2.0.68 → 2.0.70

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.68",
3
+ "version": "2.0.70",
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": {
@@ -848,6 +848,51 @@ class ApiServer {
848
848
  } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
849
849
  });
850
850
 
851
+ // Proxy a chrome.* command through a worker (content-script → SW
852
+ // message bridge). Use when the launcher SW socket is offline but the
853
+ // worker socket is alive — sending the message also wakes the SW.
854
+ // Body: { chromeId? | workerLabel?, cmd, args?, timeoutMs? }
855
+ // Available cmds (mirror of launcher.exec, run via SW message):
856
+ // tabs.query | tabs.remove | tabs.update | tabs.reload
857
+ // storage.sync.get | storage.local.get | runtime.reload | wake
858
+ this.app.post('/api/worker/exec', async (req, res) => {
859
+ try {
860
+ const body = req.body || {};
861
+ if (!body.cmd) return res.status(400).json({ error: 'cmd required' });
862
+ const opts = workerOpts(req); // accepts label/workerLabel/chromeId via req
863
+ // workerOpts only reads label/workerLabel — pass chromeId via opts
864
+ // directly when body has it (workers are addressed by socket, not
865
+ // chromeId, so we resolve chromeId → workerLabel client-side here).
866
+ if (body.chromeId && !opts.workerLabel) {
867
+ // Find any worker matching chromeId; route via its socketId.
868
+ const w = [...taskQueue.workers.values()].find(w => w.chromeId === body.chromeId);
869
+ if (w) opts.socketId = w.id;
870
+ else return res.status(404).json({ error: 'worker_not_found', chromeId: body.chromeId });
871
+ }
872
+ const timeoutMs = Math.min(Math.max(parseInt(body.timeoutMs, 10) || 10_000, 1_000), 30_000);
873
+ const result = await taskQueue.requestWorker('worker:exec', { cmd: body.cmd, args: body.args }, opts, timeoutMs);
874
+ res.json(result);
875
+ } catch (e) { res.status(503).json({ error: e.message }); }
876
+ });
877
+
878
+ // Dump extension metadata (id, version, install type, permissions) via
879
+ // the worker → SW message bridge. Useful when multiple installs of the
880
+ // same extension exist (Web Store + Load Unpacked) and we need to know
881
+ // which one is actually running.
882
+ this.app.get('/api/worker/extension-info', async (req, res) => {
883
+ try {
884
+ const opts = workerOpts(req);
885
+ const chromeIdQuery = (req.query?.chromeId || '').toString().trim();
886
+ if (chromeIdQuery && !opts.workerLabel) {
887
+ const w = [...taskQueue.workers.values()].find(w => w.chromeId === chromeIdQuery);
888
+ if (w) opts.socketId = w.id;
889
+ else return res.status(404).json({ error: 'worker_not_found', chromeId: chromeIdQuery });
890
+ }
891
+ const result = await taskQueue.requestWorker('worker:ext-info', {}, opts, 5_000);
892
+ res.json(result);
893
+ } catch (e) { res.status(503).json({ error: e.message }); }
894
+ });
895
+
851
896
  this.app.get('/api/worker/status', (req, res) => {
852
897
  const label = (req.query?.label || req.query?.workerLabel || '').toString().toLowerCase().trim();
853
898
  res.json(taskQueue.workerStatus(label || undefined));
@@ -125,6 +125,7 @@ class TaskQueue {
125
125
  id: w.id,
126
126
  email: w.email,
127
127
  chromeId: w.chromeId || null,
128
+ tabId: typeof w.tabId === 'number' ? w.tabId : null,
128
129
  status: w.status,
129
130
  currentTaskId: w.currentTaskId,
130
131
  cooldownUntil: cd,
@@ -180,6 +181,10 @@ class TaskQueue {
180
181
  // Phase 1: chromeId comes from worker meta if extension passes it,
181
182
  // else reverse-lookup from chromeIdToEmail. Phase 2 will require it.
182
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;
183
188
  if (wasUnknown && email !== existing.email) {
184
189
  // shouldn't happen — kept for clarity (email already assigned above)
185
190
  }
@@ -202,6 +207,9 @@ class TaskQueue {
202
207
  // Reverse-lookup from chromeIdToEmail fills the gap for already-bound
203
208
  // profiles. Recycle/dispatch paths prefer chromeId when present.
204
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,
205
213
  meta: { ...(meta || {}), registeredAt: Date.now() },
206
214
  status: 'idle',
207
215
  currentTaskId: null,
@@ -542,13 +550,14 @@ class TaskQueue {
542
550
  }
543
551
  const workerEmail = worker.email;
544
552
  const workerChromeId = worker.chromeId || null;
553
+ const workerTabId = typeof worker.tabId === 'number' ? worker.tabId : null;
545
554
  worker.currentTaskId = null;
546
555
  worker.status = 'idle';
547
556
  worker.lastSeen = Date.now();
548
557
 
549
558
  if (task.cancelRequestedAt) {
550
559
  // discard result; still recycle the tab so next task gets a clean slate
551
- this._recycleWorkerTab(workerEmail, workerChromeId);
560
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
552
561
  setImmediate(() => this._drain());
553
562
  return;
554
563
  }
@@ -569,7 +578,7 @@ class TaskQueue {
569
578
  // model URL pinned. AI Studio's "+ New chat" link drops the ?model=
570
579
  // query and Angular routerLink ignores href hijack — close+reopen via
571
580
  // launcher is the only deterministic way to lock the model per task.
572
- this._recycleWorkerTab(workerEmail, workerChromeId);
581
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
573
582
  // Drain right away so other idle workers pick up queued tasks while
574
583
  // this worker's tab is being recycled (~5s).
575
584
  setImmediate(() => this._drain());
@@ -584,30 +593,32 @@ class TaskQueue {
584
593
  }
585
594
  const workerEmail = worker.email;
586
595
  const workerChromeId = worker.chromeId || null;
596
+ const workerTabId = typeof worker.tabId === 'number' ? worker.tabId : null;
587
597
  await this._failOrRequeue(task, worker, error);
588
598
  // Only recycle if the worker isn't in cooldown (rate-limited workers
589
599
  // have their tab still open intentionally — recycling won't unfreeze
590
600
  // the quota and just wastes a tab cycle).
591
601
  const w = this.workers.get(socketId);
592
602
  if (!w || w.status !== 'rate_limited') {
593
- this._recycleWorkerTab(workerEmail, workerChromeId);
603
+ this._recycleWorkerTab(workerEmail, workerChromeId, workerTabId);
594
604
  } else {
595
605
  setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
596
606
  }
597
607
  }
598
608
 
599
609
  /**
600
- * Ask the launcher in the given profile to close all AI Studio tabs and
601
- * open a fresh one. The browser-side close fires worker:disconnect
602
- * clearWorker; the open creates a new tab whose worker registers ~3-5s
603
- * later. _drain runs naturally on register so any queued task picks up
604
- * 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.
605
613
  *
606
- * Prefers chromeId routing when provided (exact profile) — falls back to
607
- * workerEmail when chromeId is unknown (e.g. extension hasn't propagated
608
- * 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.
609
620
  */
610
- _recycleWorkerTab(workerEmail, chromeId = null) {
621
+ _recycleWorkerTab(workerEmail, chromeId = null, tabId = null) {
611
622
  if (!workerEmail || workerEmail.startsWith('unknown:')) return;
612
623
  // Cancel any pending idle-close — we're about to recycle, no need to close
613
624
  // separately. (This handles the race where a task lands during the idle
@@ -615,22 +626,28 @@ class TaskQueue {
615
626
  this._cancelIdleClose(workerEmail);
616
627
  // Mark workers in this profile as 'recycling' so _drain skips them. Match
617
628
  // by chromeId when present (exact profile), else by email (covers the
618
- // 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.
619
632
  for (const w of this.workers.values()) {
620
- const matches = chromeId
621
- ? w.chromeId === chromeId
622
- : 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;
623
637
  if (matches && w.status === 'idle') w.status = 'recycling';
624
638
  }
625
639
  // Route launcher calls by chromeId when known, email otherwise.
626
640
  const routeOpts = chromeId ? { chromeId } : { workerLabel: workerEmail };
627
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 } : {};
628
645
  // If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
629
646
  // skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
630
647
  const closeOnly = WORKER_IDLE_TTL_MS === 1;
631
648
  setImmediate(async () => {
632
649
  try {
633
- await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
650
+ await this.requestLauncher('launcher:close_tab', closePayload, routeOpts, 5_000);
634
651
  if (closeOnly) {
635
652
  console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
636
653
  return;
@@ -694,9 +711,35 @@ class TaskQueue {
694
711
  for (const w of this.workers.values()) {
695
712
  if (w.email === workerEmail && w.status !== 'idle') return;
696
713
  }
697
- console.log(chalk.gray(`[TaskQueue] Idle TTL expired for ${workerEmail} closing tab`));
698
- this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000)
699
- .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
+ }
700
743
  }, WORKER_IDLE_TTL_MS);
701
744
  if (t.unref) t.unref();
702
745
  this._idleCloseTimers.set(workerEmail, t);
@@ -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'})`);
@@ -490,6 +496,59 @@ function connect() {
490
496
  } catch (e) { ack && ack({ ok: false, error: e.message, stack: e.stack }); }
491
497
  });
492
498
 
499
+ // Proxy chrome.* commands to the extension background SW via runtime
500
+ // message passing. Receiving the message also wakes the SW if it was
501
+ // evicted — that's the recovery path for "launcher offline" cases
502
+ // where the launcher socket is dead but the content-script socket
503
+ // (this worker) is alive.
504
+ socket.on('worker:exec', async ({ cmd, args } = {}, ack) => {
505
+ try {
506
+ if (!cmd) return ack && ack({ ok: false, error: 'cmd_required' });
507
+ if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
508
+ return ack && ack({ ok: false, error: 'no_chrome_runtime' });
509
+ }
510
+ const reply = await new Promise((resolve) => {
511
+ try {
512
+ chrome.runtime.sendMessage({ action: 'WORKER_EXEC', cmd, args: args || {} }, (resp) => {
513
+ if (chrome.runtime.lastError) {
514
+ resolve({ ok: false, error: chrome.runtime.lastError.message });
515
+ return;
516
+ }
517
+ resolve(resp || { ok: false, error: 'no_response' });
518
+ });
519
+ } catch (err) {
520
+ resolve({ ok: false, error: err?.message || String(err) });
521
+ }
522
+ });
523
+ ack && ack(reply);
524
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
525
+ });
526
+
527
+ // Dump extension metadata via SW message — version, installType, ID,
528
+ // permissions. Useful when the user has multiple extension installs
529
+ // (Web Store + Load Unpacked) and we need to know which is live.
530
+ socket.on('worker:ext-info', async (_payload, ack) => {
531
+ try {
532
+ if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
533
+ return ack && ack({ ok: false, error: 'no_chrome_runtime' });
534
+ }
535
+ const reply = await new Promise((resolve) => {
536
+ try {
537
+ chrome.runtime.sendMessage({ action: 'WORKER_EXT_INFO' }, (resp) => {
538
+ if (chrome.runtime.lastError) {
539
+ resolve({ ok: false, error: chrome.runtime.lastError.message });
540
+ return;
541
+ }
542
+ resolve(resp || { ok: false, error: 'no_response' });
543
+ });
544
+ } catch (err) {
545
+ resolve({ ok: false, error: err?.message || String(err) });
546
+ }
547
+ });
548
+ ack && ack({ ok: true, info: reply });
549
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
550
+ });
551
+
493
552
  socket.on('debug:logs', ({ since = 0, level = null, limit = 100, clear = false } = {}, ack) => {
494
553
  try {
495
554
  let logs = logBuffer.filter(e => e.at > Number(since || 0));