vg-coder-cli 2.0.62 → 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.62",
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,
@@ -668,47 +689,94 @@ class ApiServer {
668
689
  res.json({ workers: taskQueue.listWorkers() });
669
690
  });
670
691
 
692
+ // Reset cooldown manually. Useful khi operator chắc chắn AI Studio đã hết
693
+ // rate-limit (vd quan sát qua noVNC submit prompt được) nhưng server timer
694
+ // 30 phút chưa expire. Body: { email: "alice@gmail.com" } hoặc {} cho tất cả.
695
+ this.app.post('/api/workers/reset-cooldown', (req, res) => {
696
+ const targetEmail = (req.body?.email || '').toString().toLowerCase().trim();
697
+ const reset = [];
698
+ for (const w of taskQueue.workers.values()) {
699
+ if (w.status !== 'rate_limited') continue;
700
+ if (targetEmail && w.email !== targetEmail) continue;
701
+ w.status = 'idle';
702
+ w.cooldownUntil = 0;
703
+ w.lastFailureCode = null;
704
+ w.lastFailureMessage = null;
705
+ reset.push({ id: w.id, email: w.email });
706
+ }
707
+ if (reset.length) setImmediate(() => taskQueue._drain());
708
+ res.json({ ok: true, reset, count: reset.length });
709
+ });
710
+
671
711
  this.app.get('/api/launchers', (req, res) => {
672
712
  res.json({ launchers: taskQueue.listLaunchers() });
673
713
  });
674
714
 
675
- // 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?
676
737
  this.app.get('/api/launcher/tabs', async (req, res) => {
677
738
  try {
678
- const label = (req.query?.label || req.query?.workerLabel || '').toString().trim();
679
- const opts = label ? { workerLabel: label } : { all: true };
739
+ const opts = launcherOpts({}, req.query) || { all: true };
680
740
  const result = await taskQueue.requestLauncher('launcher:list_tabs', {}, opts, 5_000);
681
741
  res.json(result);
682
- } catch (e) { res.status(503).json({ error: e.message }); }
742
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
683
743
  });
684
744
 
685
- // 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.
686
747
  this.app.post('/api/launcher/close-tab', async (req, res) => {
687
748
  try {
688
749
  const body = req.body || {};
689
- const opts = body.workerLabel ? { workerLabel: body.workerLabel } : (body.all ? { all: true } : {});
750
+ const opts = launcherOpts(body) || (body.all ? { all: true } : {});
690
751
  const payload = body.tabId != null ? { tabId: body.tabId } : {};
691
752
  const result = await taskQueue.requestLauncher('launcher:close_tab', payload, opts, 5_000);
692
753
  res.json(result);
693
- } catch (e) { res.status(503).json({ error: e.message }); }
754
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
694
755
  });
695
756
 
696
- // 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.
697
762
  this.app.post('/api/launcher/open-tab', async (req, res) => {
698
763
  try {
699
764
  const body = req.body || {};
700
- const opts = body.workerLabel ? { workerLabel: body.workerLabel } : {};
765
+ const opts = launcherOpts(body) || {};
701
766
  const payload = { url: body.url, model: body.model, active: body.active };
702
- // Pin model cho worker (theo email) _recycleWorkerTab sau task done
703
- // sẽ reopen tab với cùng model, giữ lock qua nhiều task. Nếu caller
704
- // không truyền workerLabel, pin cho TẤT CẢ launcher email hiện tại
705
- // (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).
706
771
  if (body.model) {
707
- if (body.workerLabel) {
708
- 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);
709
776
  } else {
710
777
  for (const l of taskQueue.launchers.values()) {
711
- 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);
712
780
  }
713
781
  }
714
782
  }
@@ -717,7 +785,7 @@ class ApiServer {
717
785
  // phải lớn hơn để có response thay vì error.
718
786
  const result = await taskQueue.requestLauncher('launcher:open_tab', payload, opts, 15_000);
719
787
  res.json(result);
720
- } catch (e) { res.status(503).json({ error: e.message }); }
788
+ } catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
721
789
  });
722
790
 
723
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) {
@@ -109,13 +110,22 @@ class TaskQueue {
109
110
 
110
111
  listWorkers() {
111
112
  const out = [];
113
+ const now = Date.now();
112
114
  for (const w of this.workers.values()) {
115
+ const cd = w.cooldownUntil || 0;
116
+ const remainingMs = cd > now ? cd - now : 0;
113
117
  out.push({
114
118
  id: w.id,
115
119
  email: w.email,
120
+ chromeId: w.chromeId || null,
116
121
  status: w.status,
117
122
  currentTaskId: w.currentTaskId,
118
- cooldownUntil: w.cooldownUntil || 0,
123
+ cooldownUntil: cd,
124
+ cooldownUntilISO: cd ? new Date(cd).toISOString() : null,
125
+ cooldownRemainingMs: remainingMs,
126
+ cooldownRemainingMinutes: Math.ceil(remainingMs / 60_000),
127
+ lastFailureCode: w.lastFailureCode || null,
128
+ lastFailureMessage: w.lastFailureMessage || null,
119
129
  lastSeen: w.lastSeen,
120
130
  meta: { domain: w.meta?.domain, chatId: w.meta?.chatId, registeredAt: w.meta?.registeredAt }
121
131
  });
@@ -160,14 +170,17 @@ class TaskQueue {
160
170
  if (meta?.chromeId && email && !email.startsWith('unknown:')) {
161
171
  this._bindChromeIdToEmail(meta.chromeId, email);
162
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;
163
176
  if (wasUnknown && email !== existing.email) {
164
177
  // shouldn't happen — kept for clarity (email already assigned above)
165
178
  }
166
179
  // Re-establish pin từ URL hiện tại của tab worker. Khắc phục race khi
167
- // open-tab handler set _pinnedModelByEmail nhưng launcher email lúc đó
180
+ // open-tab handler set _pinnedModelByLauncher nhưng launcher email lúc đó
168
181
  // null (chưa scrape) → pin bị mất → recycle reopen với default.
169
182
  if (meta?.pinnedModel && !email.startsWith('unknown:')) {
170
- this._pinnedModelByEmail.set(email, meta.pinnedModel);
183
+ this._pinnedModelByLauncher.set(email, meta.pinnedModel);
171
184
  }
172
185
  console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
173
186
  setImmediate(() => this._drain());
@@ -178,6 +191,10 @@ class TaskQueue {
178
191
  socket,
179
192
  id: socket.id,
180
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,
181
198
  meta: { ...(meta || {}), registeredAt: Date.now() },
182
199
  status: 'idle',
183
200
  currentTaskId: null,
@@ -192,7 +209,7 @@ class TaskQueue {
192
209
  }
193
210
  // Re-establish pin từ URL hiện tại (xem comment ở re-register branch).
194
211
  if (meta?.pinnedModel && !email.startsWith('unknown:')) {
195
- this._pinnedModelByEmail.set(email, meta.pinnedModel);
212
+ this._pinnedModelByLauncher.set(email, meta.pinnedModel);
196
213
  }
197
214
  console.log(chalk.green(`[TaskQueue] Worker registered: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
198
215
  setImmediate(() => this._drain());
@@ -243,14 +260,44 @@ class TaskQueue {
243
260
  for (const l of this.launchers.values()) {
244
261
  if (l.chromeId === chromeId) l.email = email;
245
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;
246
282
  }
247
283
 
248
284
  /**
249
- * Pick a connected launcher matching the task's pin (or any launcher if no
250
- * 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.
251
291
  */
252
292
  _pickLauncher(task) {
253
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
+ }
254
301
  const pin = task?.workerLabel ? task.workerLabel.toLowerCase() : null;
255
302
  if (pin) {
256
303
  for (const l of this.launchers.values()) {
@@ -273,8 +320,13 @@ class TaskQueue {
273
320
 
274
321
  /**
275
322
  * Send a request to one or all connected launchers and await their ack.
276
- * opts: { workerLabel?, all? } — pin to a specific profile by email,
277
- * 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.
278
330
  */
279
331
  requestLauncher(event, payload = {}, opts = {}, timeoutMs = 5_000) {
280
332
  if (opts.all) {
@@ -298,8 +350,15 @@ class TaskQueue {
298
350
  })));
299
351
  }
300
352
  return new Promise((resolve, reject) => {
301
- const launcher = this._pickLauncher({ workerLabel: opts.workerLabel || null });
302
- 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
+ }
303
362
  let done = false;
304
363
  const timer = setTimeout(() => {
305
364
  if (done) return;
@@ -351,6 +410,7 @@ class TaskQueue {
351
410
  task.timing.queuedAt = Date.now();
352
411
  task.attempts = task.attempts || [];
353
412
  task.workerLabel = task.workerLabel || null;
413
+ task.chromeId = task.chromeId || null;
354
414
  await store.saveTask(task);
355
415
  this.cache.set(task.id, task);
356
416
  this.pending.push({ id: task.id, workingDir: task.workingDir });
@@ -418,13 +478,14 @@ class TaskQueue {
418
478
  return;
419
479
  }
420
480
  const workerEmail = worker.email;
481
+ const workerChromeId = worker.chromeId || null;
421
482
  worker.currentTaskId = null;
422
483
  worker.status = 'idle';
423
484
  worker.lastSeen = Date.now();
424
485
 
425
486
  if (task.cancelRequestedAt) {
426
487
  // discard result; still recycle the tab so next task gets a clean slate
427
- this._recycleWorkerTab(workerEmail);
488
+ this._recycleWorkerTab(workerEmail, workerChromeId);
428
489
  setImmediate(() => this._drain());
429
490
  return;
430
491
  }
@@ -445,7 +506,7 @@ class TaskQueue {
445
506
  // model URL pinned. AI Studio's "+ New chat" link drops the ?model=
446
507
  // query and Angular routerLink ignores href hijack — close+reopen via
447
508
  // launcher is the only deterministic way to lock the model per task.
448
- this._recycleWorkerTab(workerEmail);
509
+ this._recycleWorkerTab(workerEmail, workerChromeId);
449
510
  // Drain right away so other idle workers pick up queued tasks while
450
511
  // this worker's tab is being recycled (~5s).
451
512
  setImmediate(() => this._drain());
@@ -459,13 +520,14 @@ class TaskQueue {
459
520
  return;
460
521
  }
461
522
  const workerEmail = worker.email;
523
+ const workerChromeId = worker.chromeId || null;
462
524
  await this._failOrRequeue(task, worker, error);
463
525
  // Only recycle if the worker isn't in cooldown (rate-limited workers
464
526
  // have their tab still open intentionally — recycling won't unfreeze
465
527
  // the quota and just wastes a tab cycle).
466
528
  const w = this.workers.get(socketId);
467
529
  if (!w || w.status !== 'rate_limited') {
468
- this._recycleWorkerTab(workerEmail);
530
+ this._recycleWorkerTab(workerEmail, workerChromeId);
469
531
  } else {
470
532
  setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
471
533
  }
@@ -477,34 +539,44 @@ class TaskQueue {
477
539
  * clearWorker; the open creates a new tab whose worker registers ~3-5s
478
540
  * later. _drain runs naturally on register so any queued task picks up
479
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).
480
546
  */
481
- _recycleWorkerTab(workerEmail) {
547
+ _recycleWorkerTab(workerEmail, chromeId = null) {
482
548
  if (!workerEmail || workerEmail.startsWith('unknown:')) return;
483
549
  // Cancel any pending idle-close — we're about to recycle, no need to close
484
550
  // separately. (This handles the race where a task lands during the idle
485
551
  // window: drain triggers recycle, idle timer becomes redundant.)
486
552
  this._cancelIdleClose(workerEmail);
487
- // Mark every worker matching this email as 'recycling' so _drain skips
488
- // them while the close+open is in flight. Without this, a queued task
489
- // can race in and get dispatched to a worker whose tab is about to
490
- // 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).
491
556
  for (const w of this.workers.values()) {
492
- if (w.email === workerEmail && w.status === 'idle') w.status = 'recycling';
493
- }
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;
494
565
  // If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
495
566
  // skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
496
567
  const closeOnly = WORKER_IDLE_TTL_MS === 1;
497
568
  setImmediate(async () => {
498
569
  try {
499
- await this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000);
570
+ await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
500
571
  if (closeOnly) {
501
572
  console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
502
573
  return;
503
574
  }
504
575
  await new Promise(r => setTimeout(r, 400));
505
- const pinned = this._pinnedModelByEmail.get(workerEmail);
576
+ const pinned = this._pinnedModelByLauncher.get(pinKey)
577
+ || this._pinnedModelByLauncher.get(workerEmail);
506
578
  const openPayload = pinned ? { model: pinned } : {};
507
- await this.requestLauncher('launcher:open_tab', openPayload, { workerLabel: workerEmail }, 8_000);
579
+ await this.requestLauncher('launcher:open_tab', openPayload, routeOpts, 8_000);
508
580
  console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}${pinned ? ` (model=${pinned})` : ''}`));
509
581
  // Schedule idle close. If new task arrives before TTL, enqueue() will
510
582
  // cancel this timer.
@@ -513,7 +585,8 @@ class TaskQueue {
513
585
  console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
514
586
  // Restore status if recycle failed and worker still in registry.
515
587
  for (const w of this.workers.values()) {
516
- 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';
517
590
  }
518
591
  setImmediate(() => this._drain());
519
592
  }
@@ -535,12 +608,23 @@ class TaskQueue {
535
608
  this._cancelIdleClose(workerEmail);
536
609
  const t = setTimeout(() => {
537
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
+ }
538
620
  // Skip if a task is already queued for this worker (or workerless).
539
621
  const hasPendingForEmail = this.pending.some(p => {
540
622
  const task = this.cache.get(p.id);
541
623
  if (!task) return false;
542
- if (!task.workerLabel) return true;
543
- 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;
544
628
  });
545
629
  if (hasPendingForEmail) return;
546
630
  // Skip if any worker for this email is busy/recycling (in-flight work).
@@ -567,6 +651,22 @@ class TaskQueue {
567
651
  // When a task is enqueued, cancel any idle-close timers that could
568
652
  // otherwise close a tab we're about to dispatch to.
569
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
+ }
570
670
  if (task.workerLabel) {
571
671
  this._cancelIdleClose(task.workerLabel.toLowerCase());
572
672
  } else {
@@ -587,6 +687,7 @@ class TaskQueue {
587
687
  for (const w of this.workers.values()) {
588
688
  if (w.id === excludeSocketId) continue;
589
689
  if (w.status === 'rate_limited') continue;
690
+ if (task.chromeId && w.chromeId !== task.chromeId) continue;
590
691
  if (task.workerLabel && w.email !== task.workerLabel.toLowerCase()) continue;
591
692
  return true;
592
693
  }
@@ -595,6 +696,10 @@ class TaskQueue {
595
696
 
596
697
  _anyWorkerCouldMatch(task) {
597
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
+ }
598
703
  if (!task.workerLabel) return true;
599
704
  const lc = task.workerLabel.toLowerCase();
600
705
  for (const w of this.workers.values()) if (w.email === lc) return true;
@@ -616,10 +721,14 @@ class TaskQueue {
616
721
  if (RL_CODES.has(code)) {
617
722
  worker.status = 'rate_limited';
618
723
  worker.cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
724
+ worker.lastFailureCode = code;
725
+ worker.lastFailureMessage = message.slice(0, 300);
619
726
  worker.currentTaskId = null;
620
727
  console.log(chalk.yellow(`[TaskQueue] Worker ${worker.email} rate-limited until ${new Date(worker.cooldownUntil).toLocaleTimeString()}`));
621
728
  } else {
622
729
  // Plain failure — worker becomes idle again
730
+ worker.lastFailureCode = code;
731
+ worker.lastFailureMessage = message.slice(0, 300);
623
732
  worker.currentTaskId = null;
624
733
  worker.status = 'idle';
625
734
  }
@@ -688,10 +797,13 @@ class TaskQueue {
688
797
  this.pending.splice(i, 1);
689
798
  continue;
690
799
  }
691
- // Pick first idle worker that matches the pin
692
- const wIdx = idleWorkers.findIndex(w =>
693
- !task.workerLabel || w.email === task.workerLabel.toLowerCase()
694
- );
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
+ });
695
807
  if (wIdx === -1) {
696
808
  if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
697
809
  i++;
@@ -769,7 +881,8 @@ class TaskQueue {
769
881
  try {
770
882
  launcher.socket.emit('launcher:open_aistudio', {
771
883
  taskId: task?.id || null,
772
- workerLabel: task?.workerLabel || null
884
+ workerLabel: task?.workerLabel || null,
885
+ chromeId: task?.chromeId || null
773
886
  }, (ack) => {
774
887
  if (ack?.ok) {
775
888
  console.log(chalk.green(`[TaskQueue] Launcher ${tag} ${ack.action} tab ${ack.tabId}`));