vg-coder-cli 2.0.63 → 2.0.64

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
@@ -105,6 +105,7 @@ ssh ... "docker exec chrome-mcp-vgcoder supervisorctl restart chromium"
105
105
  | `webhookUrl` | no | URL nhận callback khi task xong |
106
106
  | `meta` | no | JSON string, đính kèm metadata tùy ý (forward nguyên về webhook) |
107
107
  | `workerLabel` | no | Email worker để pin task vào tài khoản cụ thể (vd `alice@gmail.com`) |
108
+ | `chromeId` | no | UUID Chrome profile để pin task vào **exact profile** (precedence > `workerLabel`). Dùng khi cần Best-of-N parallel runs hoặc khi 2 profile share cùng email. Nếu `chromeId` không match launcher/worker đang kết nối → **404 `launcher_not_found`** (không silent fallback). |
108
109
 
109
110
  **Response 202**
110
111
 
@@ -133,6 +134,7 @@ ssh ... "docker exec chrome-mcp-vgcoder supervisorctl restart chromium"
133
134
  "webhook": { "attempts": [{ "at": 0, "status": 200 }], "deliveredAt": 0 },
134
135
  "worker": { "socketId": "...", "email": "alice@gmail.com", "chatId": "..." },
135
136
  "workerLabel": null,
137
+ "chromeId": null,
136
138
  "attempts": [
137
139
  { "workerEmail": "alice@gmail.com", "code": "rate_limit_exceeded", "at": 0 }
138
140
  ],
@@ -170,6 +172,7 @@ Worker dùng để fetch file đã upload. Caller thường không gọi.
170
172
  {
171
173
  "id": "abc...",
172
174
  "email": "alice@gmail.com",
175
+ "chromeId": "75cd593d-a861-4a6f-89a0-386e1e9e61c5",
173
176
  "status": "idle|busy|rate_limited",
174
177
  "currentTaskId": null,
175
178
  "cooldownUntil": 1778252081156,
@@ -211,15 +214,29 @@ Server có thể chủ động list / đóng / mở tab AI Studio trong từng p
211
214
 
212
215
  | Method | Path | Body / Query | Mô tả |
213
216
  |---|---|---|---|
214
- | `GET` | `/api/launcher/tabs` | `?label=<email>` (optional) | List tabs trong profile (hoặc all profiles nếu bỏ label) |
215
- | `POST` | `/api/launcher/close-tab` | `{ workerLabel?, tabId? }` | Đóng tab cụ thể, hoặc tất cả tab AI Studio nếu bỏ `tabId` |
216
- | `POST` | `/api/launcher/open-tab` | `{ workerLabel?, model?, url?, active? }` | Mở tab mới. `model` mặc định `gemini-3-flash-preview`. Response v2.0.52+ kèm `requested_model` / `actual_model` / `fallback_occurred` (URL-based, **không reliable** với AI Studio versions mới — xem note) |
217
+ | `GET` | `/api/launcher/tabs` | `?chromeId=<uuid>` \| `?label=<email>` (optional) | List tabs trong profile (hoặc all profiles nếu bỏ pin) |
218
+ | `POST` | `/api/launcher/close-tab` | `{ chromeId? \| workerLabel?, tabId?, all? }` | Đóng tab cụ thể, hoặc tất cả tab AI Studio nếu bỏ `tabId` |
219
+ | `POST` | `/api/launcher/open-tab` | `{ chromeId? \| workerLabel?, model?, url?, active? }` | Mở tab mới. `model` mặc định `gemini-3-flash-preview`. Response v2.0.52+ kèm `requested_model` / `actual_model` / `fallback_occurred` (URL-based, **không reliable** với AI Studio versions mới — xem note) |
220
+
221
+ **Pin precedence**: `chromeId` > `workerLabel` > default. `chromeId` chỉ exact-match một
222
+ launcher cụ thể — dùng để address **profile chưa bind email** (mới cài extension,
223
+ chưa mở AI Studio lần nào → `email: null` trong `/api/launchers`). Sau khi `open-tab`
224
+ load AI Studio, DOM scraper bind email tự động và lần sau có thể dùng `workerLabel`.
225
+
226
+ **Error contract** (v2.0.64+): explicit pin không match → HTTP **404
227
+ `launcher_not_found`**. Không có launcher nào kết nối → HTTP **503
228
+ `no_launcher_connected`**. Trước v2.0.64, mọi miss đều silent fallback sang
229
+ launcher khác — phá invariant pin của orchestrator.
217
230
 
218
231
  ```bash
219
232
  # List tab tất cả profile
220
233
  curl -s http://127.0.0.1:6868/api/launcher/tabs | jq
221
234
 
222
- # Reset tab profile alice về model multimodal
235
+ # Bootstrap profile mới (chưa email) — open AI Studio bằng chromeId
236
+ curl -X POST -d '{"chromeId":"81b9b30c-f9de-4920-899c-9ca3782fa3d7","model":"gemini-3.1-pro-preview"}' \
237
+ -H 'Content-Type: application/json' http://127.0.0.1:6868/api/launcher/open-tab
238
+
239
+ # Reset tab profile alice về model multimodal (vẫn route by email — backward compat)
223
240
  curl -X POST -d '{"workerLabel":"alice@gmail.com"}' \
224
241
  -H 'Content-Type: application/json' http://127.0.0.1:6868/api/launcher/close-tab
225
242
  curl -X POST -d '{"workerLabel":"alice@gmail.com","model":"gemini-3-flash-preview"}' \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vg-coder-cli",
3
- "version": "2.0.63",
3
+ "version": "2.0.64",
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": {
@@ -555,6 +555,26 @@ class ApiServer {
555
555
  const workerLabel = req.body?.workerLabel
556
556
  ? String(req.body.workerLabel).toLowerCase().trim()
557
557
  : null;
558
+ // chromeId pin — addresses an exact Chrome profile, independent
559
+ // of email binding. Precedence over workerLabel during dispatch.
560
+ // If the chromeId isn't connected (launcher or worker), reject
561
+ // with 404 — no silent fallback to a different profile.
562
+ const chromeId = req.body?.chromeId
563
+ ? String(req.body.chromeId).trim()
564
+ : null;
565
+ if (chromeId) {
566
+ const knownLauncher = [...taskQueue.launchers.values()]
567
+ .some(l => l.chromeId === chromeId);
568
+ const knownWorker = [...taskQueue.workers.values()]
569
+ .some(w => w.chromeId === chromeId);
570
+ if (!knownLauncher && !knownWorker) {
571
+ return res.status(404).json({
572
+ error: 'launcher_not_found',
573
+ chromeId,
574
+ message: 'No connected launcher or worker matches chromeId'
575
+ });
576
+ }
577
+ }
558
578
 
559
579
  const id = taskStore.newTaskId();
560
580
  const finalDir = taskStore.taskDir(req.workingDir, id);
@@ -584,6 +604,7 @@ class ApiServer {
584
604
  webhook: { attempts: [], deliveredAt: null },
585
605
  worker: null,
586
606
  workerLabel,
607
+ chromeId,
587
608
  attempts: [],
588
609
  result: null,
589
610
  error: null,
@@ -691,43 +712,71 @@ class ApiServer {
691
712
  res.json({ launchers: taskQueue.listLaunchers() });
692
713
  });
693
714
 
694
- // List AI Studio tabs across all profiles (or 1 if workerLabel given).
715
+ // Translate task-queue errors to HTTP status. `launcher_not_found` is the
716
+ // contract for an explicit chromeId/workerLabel pin that doesn't match any
717
+ // connected launcher — distinct from `no_launcher_connected` (no launchers
718
+ // at all). Callers rely on 404 to decide retry-vs-give-up.
719
+ const launcherErr = (e) => {
720
+ if (e.message === 'launcher_not_found') return 404;
721
+ if (e.message === 'no_launcher_connected') return 503;
722
+ return 503;
723
+ };
724
+ // Build opts from body/query. chromeId takes precedence over workerLabel
725
+ // (mirrors task-queue _pickLauncher precedence). `all: true` only honored
726
+ // when neither pin is provided.
727
+ const launcherOpts = (body = {}, query = {}) => {
728
+ const chromeId = (body.chromeId || query.chromeId || '').toString().trim();
729
+ if (chromeId) return { chromeId };
730
+ const label = (body.workerLabel || query.workerLabel || query.label || '').toString().trim();
731
+ if (label) return { workerLabel: label };
732
+ return null; // caller decides between {all:true} and {} default
733
+ };
734
+
735
+ // List AI Studio tabs across all profiles (or 1 if pinned).
736
+ // Query/body: chromeId? | workerLabel?
695
737
  this.app.get('/api/launcher/tabs', async (req, res) => {
696
738
  try {
697
- const label = (req.query?.label || req.query?.workerLabel || '').toString().trim();
698
- const opts = label ? { workerLabel: label } : { all: true };
739
+ const opts = launcherOpts({}, req.query) || { all: true };
699
740
  const result = await taskQueue.requestLauncher('launcher:list_tabs', {}, opts, 5_000);
700
741
  res.json(result);
701
- } catch (e) { res.status(503).json({ error: e.message }); }
742
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
702
743
  });
703
744
 
704
- // Close tab(s). Body: { workerLabel?, tabId? } — omit tabId to close all.
745
+ // Close tab(s). Body: { chromeId? | workerLabel?, tabId?, all? }
746
+ // Omit tabId to close all tabs in the matched profile.
705
747
  this.app.post('/api/launcher/close-tab', async (req, res) => {
706
748
  try {
707
749
  const body = req.body || {};
708
- const opts = body.workerLabel ? { workerLabel: body.workerLabel } : (body.all ? { all: true } : {});
750
+ const opts = launcherOpts(body) || (body.all ? { all: true } : {});
709
751
  const payload = body.tabId != null ? { tabId: body.tabId } : {};
710
752
  const result = await taskQueue.requestLauncher('launcher:close_tab', payload, opts, 5_000);
711
753
  res.json(result);
712
- } catch (e) { res.status(503).json({ error: e.message }); }
754
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
713
755
  });
714
756
 
715
- // Open new AI Studio tab. Body: { workerLabel?, model?, url?, active? }
757
+ // Open new AI Studio tab.
758
+ // Body: { chromeId? | workerLabel?, model?, url?, active? }
759
+ // chromeId is the only way to address an unbound launcher (email still
760
+ // null because the extension hasn't scraped AI Studio yet) — opening a
761
+ // tab via chromeId triggers the DOM scrape which binds the email.
716
762
  this.app.post('/api/launcher/open-tab', async (req, res) => {
717
763
  try {
718
764
  const body = req.body || {};
719
- const opts = body.workerLabel ? { workerLabel: body.workerLabel } : {};
765
+ const opts = launcherOpts(body) || {};
720
766
  const payload = { url: body.url, model: body.model, active: body.active };
721
- // Pin model cho worker (theo email) _recycleWorkerTab sau task done
722
- // sẽ reopen tab với cùng model, giữ lock qua nhiều task. Nếu caller
723
- // không truyền workerLabel, pin cho TẤT CẢ launcher email hiện tại
724
- // (single-tenant case phổ biến: 1 container = 1 worker).
767
+ // Pin model so _recycleWorkerTab reopens with the same model.
768
+ // Key precedence: chromeId (exact profile) > workerLabel (email).
769
+ // No pin field → broadcast: pin every connected launcher under
770
+ // both its chromeId and email (single-tenant ergonomics).
725
771
  if (body.model) {
726
- if (body.workerLabel) {
727
- taskQueue._pinnedModelByEmail.set(body.workerLabel, body.model);
772
+ if (opts.chromeId) {
773
+ taskQueue._pinnedModelByLauncher.set(opts.chromeId, body.model);
774
+ } else if (opts.workerLabel) {
775
+ taskQueue._pinnedModelByLauncher.set(opts.workerLabel, body.model);
728
776
  } else {
729
777
  for (const l of taskQueue.launchers.values()) {
730
- if (l.email) taskQueue._pinnedModelByEmail.set(l.email, body.model);
778
+ if (l.chromeId) taskQueue._pinnedModelByLauncher.set(l.chromeId, body.model);
779
+ if (l.email) taskQueue._pinnedModelByLauncher.set(l.email, body.model);
731
780
  }
732
781
  }
733
782
  }
@@ -736,7 +785,7 @@ class ApiServer {
736
785
  // phải lớn hơn để có response thay vì error.
737
786
  const result = await taskQueue.requestLauncher('launcher:open_tab', payload, opts, 15_000);
738
787
  res.json(result);
739
- } catch (e) { res.status(503).json({ error: e.message }); }
788
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
740
789
  });
741
790
 
742
791
  this.app.get('/api/worker/status', (req, res) => {
@@ -38,10 +38,11 @@ class TaskQueue {
38
38
  // AI Studio tab (free CPU). Cancelled when a new task arrives or a worker
39
39
  // recycles for that email.
40
40
  this._idleCloseTimers = new Map();
41
- // workerEmail → model string. Set qua /api/launcher/open-tab body.model;
42
- // recycle dùng để reopen tab với cùng model (giữ pin qua nhiều task).
43
- // Default null → recycle dùng AISTUDIO_DEFAULT_MODEL của extension.
44
- this._pinnedModelByEmail = new Map();
41
+ // launcher key (chromeId hoặc email fallback) → model string. Set qua
42
+ // /api/launcher/open-tab body.model; recycle dùng để reopen tab với cùng
43
+ // model (giữ pin qua nhiều task). Default null → recycle dùng
44
+ // AISTUDIO_DEFAULT_MODEL của extension.
45
+ this._pinnedModelByLauncher = new Map();
45
46
  }
46
47
 
47
48
  attachIO(io) {
@@ -116,6 +117,7 @@ class TaskQueue {
116
117
  out.push({
117
118
  id: w.id,
118
119
  email: w.email,
120
+ chromeId: w.chromeId || null,
119
121
  status: w.status,
120
122
  currentTaskId: w.currentTaskId,
121
123
  cooldownUntil: cd,
@@ -168,14 +170,17 @@ class TaskQueue {
168
170
  if (meta?.chromeId && email && !email.startsWith('unknown:')) {
169
171
  this._bindChromeIdToEmail(meta.chromeId, email);
170
172
  }
173
+ // Phase 1: chromeId comes from worker meta if extension passes it,
174
+ // else reverse-lookup from chromeIdToEmail. Phase 2 will require it.
175
+ existing.chromeId = meta?.chromeId || this._emailToChromeId(email) || existing.chromeId || null;
171
176
  if (wasUnknown && email !== existing.email) {
172
177
  // shouldn't happen — kept for clarity (email already assigned above)
173
178
  }
174
179
  // Re-establish pin từ URL hiện tại của tab worker. Khắc phục race khi
175
- // open-tab handler set _pinnedModelByEmail nhưng launcher email lúc đó
180
+ // open-tab handler set _pinnedModelByLauncher nhưng launcher email lúc đó
176
181
  // null (chưa scrape) → pin bị mất → recycle reopen với default.
177
182
  if (meta?.pinnedModel && !email.startsWith('unknown:')) {
178
- this._pinnedModelByEmail.set(email, meta.pinnedModel);
183
+ this._pinnedModelByLauncher.set(email, meta.pinnedModel);
179
184
  }
180
185
  console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
181
186
  setImmediate(() => this._drain());
@@ -186,6 +191,10 @@ class TaskQueue {
186
191
  socket,
187
192
  id: socket.id,
188
193
  email,
194
+ // Phase 1: chromeId may be absent until extension bundle propagates it.
195
+ // Reverse-lookup from chromeIdToEmail fills the gap for already-bound
196
+ // profiles. Recycle/dispatch paths prefer chromeId when present.
197
+ chromeId: meta?.chromeId || this._emailToChromeId(email) || null,
189
198
  meta: { ...(meta || {}), registeredAt: Date.now() },
190
199
  status: 'idle',
191
200
  currentTaskId: null,
@@ -200,7 +209,7 @@ class TaskQueue {
200
209
  }
201
210
  // Re-establish pin từ URL hiện tại (xem comment ở re-register branch).
202
211
  if (meta?.pinnedModel && !email.startsWith('unknown:')) {
203
- this._pinnedModelByEmail.set(email, meta.pinnedModel);
212
+ this._pinnedModelByLauncher.set(email, meta.pinnedModel);
204
213
  }
205
214
  console.log(chalk.green(`[TaskQueue] Worker registered: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
206
215
  setImmediate(() => this._drain());
@@ -251,14 +260,44 @@ class TaskQueue {
251
260
  for (const l of this.launchers.values()) {
252
261
  if (l.chromeId === chromeId) l.email = email;
253
262
  }
263
+ // Backfill worker.chromeId for already-connected workers in this profile
264
+ // (Phase 1 reverse-lookup: workers register before launcher binds email).
265
+ for (const w of this.workers.values()) {
266
+ if (!w.chromeId && (w.email || '').toLowerCase() === email.toLowerCase()) {
267
+ w.chromeId = chromeId;
268
+ }
269
+ }
270
+ }
271
+
272
+ // Reverse lookup: find chromeId bound to email. Returns the first match —
273
+ // if two launchers login the same email (test vs prod), this is ambiguous
274
+ // and the caller should prefer explicit chromeId routing instead.
275
+ _emailToChromeId(email) {
276
+ if (!email || email.startsWith('unknown:')) return null;
277
+ const lc = email.toLowerCase();
278
+ for (const [cid, em] of this.chromeIdToEmail) {
279
+ if (em && em.toLowerCase() === lc) return cid;
280
+ }
281
+ return null;
254
282
  }
255
283
 
256
284
  /**
257
- * Pick a connected launcher matching the task's pin (or any launcher if no
258
- * pin). Returns null if none.
285
+ * Pick a connected launcher. Precedence: explicit chromeId from opts/task,
286
+ * then workerLabel (email), then any launcher. Returns null if the explicit
287
+ * pin doesn't match a connected launcher — callers MUST treat this as a
288
+ * hard miss (404), not silently dispatch elsewhere.
289
+ *
290
+ * Accepts either a task ({ chromeId?, workerLabel? }) or raw opts object.
259
291
  */
260
292
  _pickLauncher(task) {
261
293
  if (!this.launchers.size) return null;
294
+ const chromeId = task?.chromeId ? String(task.chromeId).trim() : null;
295
+ if (chromeId) {
296
+ for (const l of this.launchers.values()) {
297
+ if (l.chromeId === chromeId) return l;
298
+ }
299
+ return null; // explicit chromeId pin — no silent fallback
300
+ }
262
301
  const pin = task?.workerLabel ? task.workerLabel.toLowerCase() : null;
263
302
  if (pin) {
264
303
  for (const l of this.launchers.values()) {
@@ -281,8 +320,13 @@ class TaskQueue {
281
320
 
282
321
  /**
283
322
  * Send a request to one or all connected launchers and await their ack.
284
- * opts: { workerLabel?, all? } — pin to a specific profile by email,
285
- * or `all: true` to broadcast and aggregate replies from every launcher.
323
+ * opts: { chromeId?, workerLabel?, all? } — chromeId pins to an exact
324
+ * Chrome profile (precedence over workerLabel), workerLabel pins by email,
325
+ * `all: true` broadcasts and aggregates replies from every launcher.
326
+ *
327
+ * Rejects with `launcher_not_found` (not `no_launcher_connected`) when an
328
+ * explicit pin fails to match — callers can map that to HTTP 404 instead of
329
+ * silently falling back to a different profile.
286
330
  */
287
331
  requestLauncher(event, payload = {}, opts = {}, timeoutMs = 5_000) {
288
332
  if (opts.all) {
@@ -306,8 +350,15 @@ class TaskQueue {
306
350
  })));
307
351
  }
308
352
  return new Promise((resolve, reject) => {
309
- const launcher = this._pickLauncher({ workerLabel: opts.workerLabel || null });
310
- if (!launcher) return reject(new Error('no_launcher_connected'));
353
+ const hasExplicitPin = !!(opts.chromeId || opts.workerLabel);
354
+ const launcher = this._pickLauncher({
355
+ chromeId: opts.chromeId || null,
356
+ workerLabel: opts.workerLabel || null,
357
+ });
358
+ if (!launcher) {
359
+ if (hasExplicitPin) return reject(new Error('launcher_not_found'));
360
+ return reject(new Error('no_launcher_connected'));
361
+ }
311
362
  let done = false;
312
363
  const timer = setTimeout(() => {
313
364
  if (done) return;
@@ -359,6 +410,7 @@ class TaskQueue {
359
410
  task.timing.queuedAt = Date.now();
360
411
  task.attempts = task.attempts || [];
361
412
  task.workerLabel = task.workerLabel || null;
413
+ task.chromeId = task.chromeId || null;
362
414
  await store.saveTask(task);
363
415
  this.cache.set(task.id, task);
364
416
  this.pending.push({ id: task.id, workingDir: task.workingDir });
@@ -426,13 +478,14 @@ class TaskQueue {
426
478
  return;
427
479
  }
428
480
  const workerEmail = worker.email;
481
+ const workerChromeId = worker.chromeId || null;
429
482
  worker.currentTaskId = null;
430
483
  worker.status = 'idle';
431
484
  worker.lastSeen = Date.now();
432
485
 
433
486
  if (task.cancelRequestedAt) {
434
487
  // discard result; still recycle the tab so next task gets a clean slate
435
- this._recycleWorkerTab(workerEmail);
488
+ this._recycleWorkerTab(workerEmail, workerChromeId);
436
489
  setImmediate(() => this._drain());
437
490
  return;
438
491
  }
@@ -453,7 +506,7 @@ class TaskQueue {
453
506
  // model URL pinned. AI Studio's "+ New chat" link drops the ?model=
454
507
  // query and Angular routerLink ignores href hijack — close+reopen via
455
508
  // launcher is the only deterministic way to lock the model per task.
456
- this._recycleWorkerTab(workerEmail);
509
+ this._recycleWorkerTab(workerEmail, workerChromeId);
457
510
  // Drain right away so other idle workers pick up queued tasks while
458
511
  // this worker's tab is being recycled (~5s).
459
512
  setImmediate(() => this._drain());
@@ -467,13 +520,14 @@ class TaskQueue {
467
520
  return;
468
521
  }
469
522
  const workerEmail = worker.email;
523
+ const workerChromeId = worker.chromeId || null;
470
524
  await this._failOrRequeue(task, worker, error);
471
525
  // Only recycle if the worker isn't in cooldown (rate-limited workers
472
526
  // have their tab still open intentionally — recycling won't unfreeze
473
527
  // the quota and just wastes a tab cycle).
474
528
  const w = this.workers.get(socketId);
475
529
  if (!w || w.status !== 'rate_limited') {
476
- this._recycleWorkerTab(workerEmail);
530
+ this._recycleWorkerTab(workerEmail, workerChromeId);
477
531
  } else {
478
532
  setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
479
533
  }
@@ -485,34 +539,44 @@ class TaskQueue {
485
539
  * clearWorker; the open creates a new tab whose worker registers ~3-5s
486
540
  * later. _drain runs naturally on register so any queued task picks up
487
541
  * the fresh worker. Fire-and-forget — errors are logged but don't bubble.
542
+ *
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).
488
546
  */
489
- _recycleWorkerTab(workerEmail) {
547
+ _recycleWorkerTab(workerEmail, chromeId = null) {
490
548
  if (!workerEmail || workerEmail.startsWith('unknown:')) return;
491
549
  // Cancel any pending idle-close — we're about to recycle, no need to close
492
550
  // separately. (This handles the race where a task lands during the idle
493
551
  // window: drain triggers recycle, idle timer becomes redundant.)
494
552
  this._cancelIdleClose(workerEmail);
495
- // Mark every worker matching this email as 'recycling' so _drain skips
496
- // them while the close+open is in flight. Without this, a queued task
497
- // can race in and get dispatched to a worker whose tab is about to
498
- // close, leading to a stuck "running" task that never completes.
553
+ // Mark workers in this profile as 'recycling' so _drain skips them. Match
554
+ // by chromeId when present (exact profile), else by email (covers the
555
+ // chromeId-unknown path).
499
556
  for (const w of this.workers.values()) {
500
- if (w.email === workerEmail && w.status === 'idle') w.status = 'recycling';
501
- }
557
+ const matches = chromeId
558
+ ? w.chromeId === chromeId
559
+ : w.email === workerEmail;
560
+ if (matches && w.status === 'idle') w.status = 'recycling';
561
+ }
562
+ // Route launcher calls by chromeId when known, email otherwise.
563
+ const routeOpts = chromeId ? { chromeId } : { workerLabel: workerEmail };
564
+ const pinKey = chromeId || workerEmail;
502
565
  // If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
503
566
  // skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
504
567
  const closeOnly = WORKER_IDLE_TTL_MS === 1;
505
568
  setImmediate(async () => {
506
569
  try {
507
- await this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000);
570
+ await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
508
571
  if (closeOnly) {
509
572
  console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
510
573
  return;
511
574
  }
512
575
  await new Promise(r => setTimeout(r, 400));
513
- const pinned = this._pinnedModelByEmail.get(workerEmail);
576
+ const pinned = this._pinnedModelByLauncher.get(pinKey)
577
+ || this._pinnedModelByLauncher.get(workerEmail);
514
578
  const openPayload = pinned ? { model: pinned } : {};
515
- await this.requestLauncher('launcher:open_tab', openPayload, { workerLabel: workerEmail }, 8_000);
579
+ await this.requestLauncher('launcher:open_tab', openPayload, routeOpts, 8_000);
516
580
  console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}${pinned ? ` (model=${pinned})` : ''}`));
517
581
  // Schedule idle close. If new task arrives before TTL, enqueue() will
518
582
  // cancel this timer.
@@ -521,7 +585,8 @@ class TaskQueue {
521
585
  console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
522
586
  // Restore status if recycle failed and worker still in registry.
523
587
  for (const w of this.workers.values()) {
524
- if (w.email === workerEmail && w.status === 'recycling') w.status = 'idle';
588
+ const matches = chromeId ? w.chromeId === chromeId : w.email === workerEmail;
589
+ if (matches && w.status === 'recycling') w.status = 'idle';
525
590
  }
526
591
  setImmediate(() => this._drain());
527
592
  }
@@ -543,12 +608,23 @@ class TaskQueue {
543
608
  this._cancelIdleClose(workerEmail);
544
609
  const t = setTimeout(() => {
545
610
  this._idleCloseTimers.delete(workerEmail);
611
+ // Collect chromeIds bound to this email so we can spot chromeId-pinned
612
+ // tasks targeting the same profile.
613
+ const myChromeIds = new Set();
614
+ for (const w of this.workers.values()) {
615
+ if (w.email === workerEmail && w.chromeId) myChromeIds.add(w.chromeId);
616
+ }
617
+ for (const [cid, em] of this.chromeIdToEmail) {
618
+ if (em === workerEmail) myChromeIds.add(cid);
619
+ }
546
620
  // Skip if a task is already queued for this worker (or workerless).
547
621
  const hasPendingForEmail = this.pending.some(p => {
548
622
  const task = this.cache.get(p.id);
549
623
  if (!task) return false;
550
- if (!task.workerLabel) return true;
551
- return task.workerLabel.toLowerCase() === workerEmail;
624
+ if (task.chromeId && myChromeIds.has(task.chromeId)) return true;
625
+ if (!task.workerLabel && !task.chromeId) return true;
626
+ if (task.workerLabel && task.workerLabel.toLowerCase() === workerEmail) return true;
627
+ return false;
552
628
  });
553
629
  if (hasPendingForEmail) return;
554
630
  // Skip if any worker for this email is busy/recycling (in-flight work).
@@ -575,6 +651,22 @@ class TaskQueue {
575
651
  // When a task is enqueued, cancel any idle-close timers that could
576
652
  // otherwise close a tab we're about to dispatch to.
577
653
  if (!task) return;
654
+ if (task.chromeId) {
655
+ // chromeId pin → resolve to email via connected worker (or chromeIdToEmail
656
+ // map) and cancel that email's timer. Idle timers are still keyed by
657
+ // email since the close-tab socket message routes per-launcher anyway.
658
+ let email = this.chromeIdToEmail.get(task.chromeId);
659
+ if (!email) {
660
+ for (const w of this.workers.values()) {
661
+ if (w.chromeId === task.chromeId && w.email && !w.email.startsWith('unknown:')) {
662
+ email = w.email;
663
+ break;
664
+ }
665
+ }
666
+ }
667
+ if (email) this._cancelIdleClose(email);
668
+ return;
669
+ }
578
670
  if (task.workerLabel) {
579
671
  this._cancelIdleClose(task.workerLabel.toLowerCase());
580
672
  } else {
@@ -595,6 +687,7 @@ class TaskQueue {
595
687
  for (const w of this.workers.values()) {
596
688
  if (w.id === excludeSocketId) continue;
597
689
  if (w.status === 'rate_limited') continue;
690
+ if (task.chromeId && w.chromeId !== task.chromeId) continue;
598
691
  if (task.workerLabel && w.email !== task.workerLabel.toLowerCase()) continue;
599
692
  return true;
600
693
  }
@@ -603,6 +696,10 @@ class TaskQueue {
603
696
 
604
697
  _anyWorkerCouldMatch(task) {
605
698
  if (!this.workers.size) return false;
699
+ if (task.chromeId) {
700
+ for (const w of this.workers.values()) if (w.chromeId === task.chromeId) return true;
701
+ return false;
702
+ }
606
703
  if (!task.workerLabel) return true;
607
704
  const lc = task.workerLabel.toLowerCase();
608
705
  for (const w of this.workers.values()) if (w.email === lc) return true;
@@ -700,10 +797,13 @@ class TaskQueue {
700
797
  this.pending.splice(i, 1);
701
798
  continue;
702
799
  }
703
- // Pick first idle worker that matches the pin
704
- const wIdx = idleWorkers.findIndex(w =>
705
- !task.workerLabel || w.email === task.workerLabel.toLowerCase()
706
- );
800
+ // Pick first idle worker that matches the pin. chromeId takes
801
+ // precedence over workerLabel — explicit profile pin is authoritative.
802
+ const wIdx = idleWorkers.findIndex(w => {
803
+ if (task.chromeId) return w.chromeId === task.chromeId;
804
+ if (task.workerLabel) return w.email === task.workerLabel.toLowerCase();
805
+ return true;
806
+ });
707
807
  if (wIdx === -1) {
708
808
  if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
709
809
  i++;
@@ -781,7 +881,8 @@ class TaskQueue {
781
881
  try {
782
882
  launcher.socket.emit('launcher:open_aistudio', {
783
883
  taskId: task?.id || null,
784
- workerLabel: task?.workerLabel || null
884
+ workerLabel: task?.workerLabel || null,
885
+ chromeId: task?.chromeId || null
785
886
  }, (ack) => {
786
887
  if (ack?.ok) {
787
888
  console.log(chalk.green(`[TaskQueue] Launcher ${tag} ${ack.action} tab ${ack.tabId}`));