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
package/src/server/api-server.js
CHANGED
|
@@ -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', () => {
|
|
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:
|
package/src/server/task-queue.js
CHANGED
|
@@ -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
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
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
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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',
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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'})`);
|