vg-coder-cli 2.0.46 → 2.0.47

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.
@@ -0,0 +1,448 @@
1
+ // Task Worker — runs inside the AI Studio page (shadow-DOM bundled).
2
+ // Connects to localhost:6868 via Socket.IO, registers as the worker,
3
+ // and executes incoming task:execute jobs by calling window.AIChat.
4
+
5
+ import { io } from 'socket.io-client';
6
+ import { API_BASE } from '../config.js';
7
+
8
+ const ALLOWED_HOSTS = ['aistudio.google.com'];
9
+ const HEARTBEAT_MS = 15_000;
10
+
11
+ let socket = null;
12
+ let heartbeatTimer = null;
13
+ let emailAbort = null;
14
+ const cancelFlags = new Set(); // taskIds that received task:cancel
15
+
16
+ // ---- Console / error ring buffer ----
17
+ // Captures recent log entries so a remote caller can ask "what just went
18
+ // wrong?" without an attached devtools panel. Hook is installed once at
19
+ // init; entries persist until the buffer wraps.
20
+ const LOG_BUFFER_MAX = 200;
21
+ const logBuffer = []; // [{ at, level, source, message }]
22
+ let logCaptureInstalled = false;
23
+
24
+ function pushLog(level, args, source = 'console') {
25
+ let message;
26
+ try {
27
+ message = args.map(a => {
28
+ if (a instanceof Error) return `${a.name}: ${a.message}\n${a.stack || ''}`;
29
+ if (typeof a === 'object' && a !== null) {
30
+ try { return JSON.stringify(a); } catch (_) { return String(a); }
31
+ }
32
+ return String(a);
33
+ }).join(' ');
34
+ } catch (_) { message = '<serialize-failed>'; }
35
+ if (logBuffer.length >= LOG_BUFFER_MAX) logBuffer.shift();
36
+ logBuffer.push({ at: Date.now(), level, source, message: message.slice(0, 4000) });
37
+ }
38
+
39
+ function installLogCapture() {
40
+ if (logCaptureInstalled) return;
41
+ logCaptureInstalled = true;
42
+ ['log', 'info', 'warn', 'error', 'debug'].forEach(lvl => {
43
+ const orig = console[lvl]?.bind(console) || console.log.bind(console);
44
+ console[lvl] = function (...args) {
45
+ pushLog(lvl, args);
46
+ try { orig(...args); } catch (_) {}
47
+ };
48
+ });
49
+ window.addEventListener('error', (e) => {
50
+ pushLog('error', [`window.error: ${e.message || ''}`, e.error?.stack || `at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`], 'window');
51
+ });
52
+ window.addEventListener('unhandledrejection', (e) => {
53
+ const r = e.reason;
54
+ const msg = r instanceof Error ? r.message : (typeof r === 'object' ? JSON.stringify(r) : String(r));
55
+ pushLog('error', [`unhandledrejection: ${msg}`, r?.stack || ''], 'promise');
56
+ });
57
+ }
58
+
59
+ function isAllowedHost() {
60
+ try {
61
+ return ALLOWED_HOSTS.some(h => location.hostname.endsWith(h));
62
+ } catch (_) { return false; }
63
+ }
64
+
65
+ async function waitForAIChat(timeoutMs = 30_000) {
66
+ const start = Date.now();
67
+ while (Date.now() - start < timeoutMs) {
68
+ if (window.AIChat && typeof window.AIChat.send === 'function') return true;
69
+ await new Promise(r => setTimeout(r, 300));
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Extract the signed-in Google account email from AI Studio's DOM.
76
+ * Tries several selectors + a generic aria-label sweep, then falls back to
77
+ * scanning page text for the first email-shaped string.
78
+ */
79
+ const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w-]+/;
80
+
81
+ function extractEmail() {
82
+ const candidates = [
83
+ () => document.querySelector('a[aria-label*="@"]')?.getAttribute('aria-label'),
84
+ () => document.querySelector('a[href^="https://accounts.google.com/SignOutOptions"]')?.getAttribute('aria-label'),
85
+ () => document.querySelector('img[alt*="@"]')?.alt,
86
+ () => document.querySelector('[data-email]')?.getAttribute('data-email'),
87
+ () => Array.from(document.querySelectorAll('[aria-label]'))
88
+ .map(e => e.getAttribute('aria-label') || '')
89
+ .find(t => EMAIL_RE.test(t))
90
+ ];
91
+ for (const fn of candidates) {
92
+ try {
93
+ const raw = (fn() || '').toString();
94
+ const m = raw.match(EMAIL_RE);
95
+ if (m) return m[0].toLowerCase();
96
+ } catch (_) {}
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Wait for the email to appear in DOM. Retries forever (every `pollMs`) so a
103
+ * worker that registers on /welcome (pre-login) and only later signs in still
104
+ * gets its email recorded eventually. Returns null only if `signal.aborted`.
105
+ */
106
+ async function resolveEmail(pollMs = 3000, signal = null) {
107
+ while (true) {
108
+ if (signal?.aborted) return null;
109
+ const email = extractEmail();
110
+ if (email) return email;
111
+ await new Promise(r => setTimeout(r, pollMs));
112
+ }
113
+ }
114
+
115
+ async function fetchAsFile(url, name, mime) {
116
+ // Use direct fetch (PNA is already granted on first click) and pull a
117
+ // real Blob — passing a Blob into File() preserves binary fidelity and
118
+ // matches what the dashboard agent panel sends from clipboard / drag-drop.
119
+ //
120
+ // Do NOT route through window.vetgoFetch for binary content: chrome.runtime
121
+ // message-passing between background SW → content script → MAIN world
122
+ // structured-clones the ArrayBuffer in a way that breaks `instanceof
123
+ // ArrayBuffer` on the receiving end (verified with SHA-256 mismatch via
124
+ // SubtleCrypto). For text it's fine; for binary it silently corrupts.
125
+ const r = await fetch(url);
126
+ if (!r.ok) throw new Error(`Failed to fetch ${name}: HTTP ${r.status}`);
127
+ const blob = await r.blob();
128
+ return new File([blob], name, { type: mime || blob.type || 'application/octet-stream' });
129
+ }
130
+
131
+ // Wait for the assistant turn to actually render (not just the user's echo turn).
132
+ // AI Studio renders the user turn first, then the model turn. We need ≥ baseline + 2.
133
+ async function waitForNewAssistantTurn(baselineCount, timeoutMs = 90_000) {
134
+ const start = Date.now();
135
+ while (Date.now() - start < timeoutMs) {
136
+ // Surface rate-limit / quota errors early — no point waiting for a turn
137
+ // that will never appear.
138
+ const rl = detectRateLimit();
139
+ if (rl) { const e = new Error(rl.message); e.code = rl.code; throw e; }
140
+
141
+ const turns = document.querySelectorAll('ms-chat-turn');
142
+ if (turns.length >= baselineCount + 2) {
143
+ const last = turns[turns.length - 1];
144
+ const role = last.querySelector('[data-turn-role]')?.getAttribute('data-turn-role');
145
+ if (role === 'Model') return true;
146
+ }
147
+ await new Promise(r => setTimeout(r, 250));
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /**
153
+ * Inspect the page for known rate-limit / quota / token-count / error states.
154
+ * Returns { code, message } if any is present; null otherwise.
155
+ * Selectors based on observed AI Studio DOM.
156
+ */
157
+ function detectRateLimit() {
158
+ // Snackbar toast (top-level, e.g. "Failed to generate content: user has exceeded quota")
159
+ const toast = document.querySelector('mat-snack-bar-container, .mdc-snackbar, .cdk-overlay-container .mat-mdc-snack-bar-label');
160
+ if (toast) {
161
+ const t = (toast.textContent || '').trim();
162
+ if (/exceeded quota|quota/i.test(t)) return { code: 'quota_exceeded', message: t.slice(0, 500) };
163
+ if (/rate limit/i.test(t)) return { code: 'rate_limit_exceeded', message: t.slice(0, 500) };
164
+ if (/failed to generate/i.test(t)) return { code: 'generation_failed', message: t.slice(0, 500) };
165
+ }
166
+
167
+ // Inline model-error inside the last assistant turn
168
+ const modelErr = document.querySelector('.model-error');
169
+ if (modelErr) {
170
+ const text = (modelErr.textContent || '').trim();
171
+ if (/rate limit/i.test(text)) return { code: 'rate_limit_exceeded', message: text };
172
+ if (/quota/i.test(text)) return { code: 'quota_exceeded', message: text };
173
+ if (/internal error/i.test(text)) return { code: 'model_internal_error', message: text };
174
+ if (text) return { code: 'model_error', message: text };
175
+ }
176
+
177
+ // Standalone quota / upgrade callout
178
+ const callout = document.querySelector('ms-upgrade-options-callout, .quota-exceeded-message');
179
+ if (callout) {
180
+ return { code: 'quota_exceeded', message: (callout.textContent || 'Quota exceeded').trim().slice(0, 500) };
181
+ }
182
+
183
+ // Token count failure
184
+ const tokenErr = document.querySelector('.token-status-error');
185
+ if (tokenErr) {
186
+ return { code: 'token_count_failed', message: (tokenErr.textContent || 'Token count failed').trim() };
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ function throwIfRateLimited(stage = '') {
193
+ const rl = detectRateLimit();
194
+ if (rl) {
195
+ const e = new Error(`[${stage}] ${rl.message}`);
196
+ e.code = rl.code;
197
+ throw e;
198
+ }
199
+ }
200
+
201
+ async function handleTaskExecute(payload) {
202
+ const { taskId, prompt, files = [] } = payload || {};
203
+ if (!taskId) return;
204
+
205
+ console.log(`[TaskWorker] Executing ${taskId} (${files.length} file(s))`);
206
+
207
+ try {
208
+ // 1. Open a fresh chat for this task — isolates context, avoids stale results
209
+ if (typeof window.AIChat.startNewChat === 'function') {
210
+ const ok = await window.AIChat.startNewChat();
211
+ console.log(`[TaskWorker] startNewChat → ${ok ? 'ready' : 'fallback (continuing)'}`);
212
+ }
213
+ await new Promise(r => setTimeout(r, 600)); // settle after navigation
214
+
215
+ if (cancelFlags.has(taskId)) {
216
+ cancelFlags.delete(taskId);
217
+ console.log(`[TaskWorker] ${taskId} canceled before send — skipping`);
218
+ return;
219
+ }
220
+
221
+ // 2. Snapshot turn count before sending so we can detect the new model turn
222
+ const baselineTurns = document.querySelectorAll('ms-chat-turn').length;
223
+
224
+ const blobs = await Promise.all(
225
+ files.map(f => fetchAsFile(f.url, f.name, f.mime))
226
+ );
227
+
228
+ // 3. Send prompt + files; AIChat.send waits for the Run button to settle
229
+ await window.AIChat.send({ prompt: prompt || '', files: blobs });
230
+
231
+ // 3b. Immediately surface rate-limit / quota errors that AI Studio shows
232
+ // via toast or inline error before any model turn renders.
233
+ throwIfRateLimited('post_send');
234
+
235
+ // 4. Wait for the assistant's turn to actually render in the DOM.
236
+ // waitForNewAssistantTurn also probes for rate-limit during the wait.
237
+ const turnReady = await waitForNewAssistantTurn(baselineTurns);
238
+ if (!turnReady) console.warn(`[TaskWorker] ${taskId} — assistant turn not detected; copying anyway`);
239
+
240
+ // 5. Re-check after turn renders — error turns count as turns too.
241
+ throwIfRateLimited('post_turn');
242
+
243
+ // 6. Extra settle so streaming finalizes before we trigger Copy-as-markdown
244
+ await new Promise(r => setTimeout(r, 1500));
245
+
246
+ if (cancelFlags.has(taskId)) {
247
+ cancelFlags.delete(taskId);
248
+ console.log(`[TaskWorker] ${taskId} canceled — discarding result`);
249
+ return;
250
+ }
251
+
252
+ const markdown = await window.AIChat.copyLastTurnAsMarkdown();
253
+ const chatId = window.AIChat.getChatIdFromUrl?.() || null;
254
+
255
+ if (cancelFlags.has(taskId)) {
256
+ cancelFlags.delete(taskId);
257
+ console.log(`[TaskWorker] ${taskId} canceled post-copy — discarding`);
258
+ return;
259
+ }
260
+
261
+ socket.emit('task:complete', { taskId, markdown, chatId });
262
+ console.log(`[TaskWorker] Completed ${taskId}`);
263
+ } catch (err) {
264
+ console.error(`[TaskWorker] Failed ${taskId}:`, err);
265
+ socket.emit('task:failed', {
266
+ taskId,
267
+ code: err?.code || 'execution_error',
268
+ message: err?.message || String(err)
269
+ });
270
+ }
271
+ }
272
+
273
+ export function initTaskWorker() {
274
+ if (!isAllowedHost()) {
275
+ console.log('[TaskWorker] Skipping init — not on aistudio.google.com');
276
+ return;
277
+ }
278
+ if (socket) return;
279
+ installLogCapture();
280
+
281
+ waitForAIChat().then((ready) => {
282
+ if (!ready) {
283
+ console.warn('[TaskWorker] window.AIChat not ready — task worker disabled');
284
+ return;
285
+ }
286
+ connect();
287
+ });
288
+ }
289
+
290
+ function connect() {
291
+ socket = io(API_BASE, { transports: ['websocket', 'polling'] });
292
+
293
+ socket.on('connect', () => {
294
+ console.log(`[TaskWorker] Connected as ${socket.id}`);
295
+
296
+ // Reset any outstanding email retry from a previous session
297
+ if (emailAbort) emailAbort.abort();
298
+ emailAbort = new AbortController();
299
+
300
+ // Register with whatever email we have right now (may be null on cold load).
301
+ const initialEmail = extractEmail();
302
+ const chromeId = (window.vetgo && window.vetgo.chromeId) || null;
303
+ socket.emit('worker:register', {
304
+ domain: location.hostname,
305
+ chatId: window.AIChat?.getChatIdFromUrl?.() || null,
306
+ userAgent: navigator.userAgent,
307
+ email: initialEmail,
308
+ chromeId
309
+ });
310
+ console.log(`[TaskWorker] Initial email: ${initialEmail || '(pending)'}, chromeId: ${chromeId || '(none)'}`);
311
+
312
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
313
+ heartbeatTimer = setInterval(() => {
314
+ try { socket.emit('worker:heartbeat'); } catch (_) {}
315
+ }, HEARTBEAT_MS);
316
+
317
+ // Detached, infinite email retry — keeps polling DOM until email shows up.
318
+ // Stops when socket disconnects (handled below).
319
+ if (!initialEmail) {
320
+ (async () => {
321
+ try {
322
+ const resolved = await resolveEmail(3000, emailAbort?.signal);
323
+ if (!resolved || !socket.connected) return;
324
+ socket.emit('worker:register', {
325
+ domain: location.hostname,
326
+ chatId: window.AIChat?.getChatIdFromUrl?.() || null,
327
+ userAgent: navigator.userAgent,
328
+ email: resolved,
329
+ chromeId: (window.vetgo && window.vetgo.chromeId) || null
330
+ });
331
+ console.log(`[TaskWorker] Re-registered with email: ${resolved}`);
332
+ } catch (err) {
333
+ console.error('[TaskWorker] Email retry failed:', err);
334
+ }
335
+ })();
336
+ }
337
+ });
338
+
339
+ socket.on('worker:rejected', (info) => {
340
+ console.warn('[TaskWorker] Worker rejected:', info);
341
+ });
342
+
343
+ socket.on('worker:replaced', () => {
344
+ console.warn('[TaskWorker] Replaced by newer worker — disconnecting');
345
+ try { socket.disconnect(); } catch (_) {}
346
+ });
347
+
348
+ socket.on('task:execute', handleTaskExecute);
349
+
350
+ socket.on('task:cancel', ({ taskId }) => {
351
+ if (taskId) cancelFlags.add(taskId);
352
+ console.log(`[TaskWorker] Cancel requested for ${taskId}`);
353
+ });
354
+
355
+ // ----- Remote debug surface -----
356
+ socket.on('debug:url', (_payload, ack) => {
357
+ try {
358
+ ack && ack({
359
+ ok: true,
360
+ href: location.href,
361
+ pathname: location.pathname,
362
+ hostname: location.hostname,
363
+ title: document.title,
364
+ chatId: window.AIChat?.getChatIdFromUrl?.() || null,
365
+ rateLimit: detectRateLimit() || null
366
+ });
367
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
368
+ });
369
+
370
+ socket.on('debug:probe', ({ selector, kind = 'text' } = {}, ack) => {
371
+ try {
372
+ const els = Array.from(document.querySelectorAll(selector));
373
+ const out = els.slice(0, 50).map(el => {
374
+ if (kind === 'html') return el.innerHTML;
375
+ if (kind === 'outer') return el.outerHTML;
376
+ if (kind === 'attrs') return Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value]));
377
+ return (el.textContent || '').trim();
378
+ });
379
+ ack && ack({ ok: true, count: els.length, results: out });
380
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
381
+ });
382
+
383
+ socket.on('debug:dom', ({ selector, maxBytes = 200_000 } = {}, ack) => {
384
+ try {
385
+ const root = selector ? document.querySelector(selector) : document.documentElement;
386
+ if (!root) return ack && ack({ ok: false, error: 'selector_not_found' });
387
+ let html = root.outerHTML || '';
388
+ const truncated = html.length > maxBytes;
389
+ if (truncated) html = html.slice(0, maxBytes);
390
+ ack && ack({ ok: true, length: root.outerHTML.length, truncated, html });
391
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
392
+ });
393
+
394
+ socket.on('debug:eval', async ({ code } = {}, ack) => {
395
+ try {
396
+ // eslint-disable-next-line no-new-func
397
+ const fn = new Function(`"use strict"; return (async () => { ${code} })();`);
398
+ const value = await fn();
399
+ // Best-effort safe-serialize
400
+ let serialized;
401
+ try { serialized = JSON.parse(JSON.stringify(value, (_k, v) => {
402
+ if (v instanceof Element) return { _element: v.tagName, id: v.id, classes: v.className };
403
+ if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`;
404
+ return v;
405
+ })); }
406
+ catch (_) { serialized = String(value); }
407
+ ack && ack({ ok: true, value: serialized });
408
+ } catch (e) { ack && ack({ ok: false, error: e.message, stack: e.stack }); }
409
+ });
410
+
411
+ socket.on('debug:logs', ({ since = 0, level = null, limit = 100, clear = false } = {}, ack) => {
412
+ try {
413
+ let logs = logBuffer.filter(e => e.at > Number(since || 0));
414
+ if (level) {
415
+ const lvls = Array.isArray(level) ? level : [level];
416
+ logs = logs.filter(e => lvls.includes(e.level));
417
+ }
418
+ if (limit > 0) logs = logs.slice(-limit);
419
+ const total = logBuffer.length;
420
+ const errors = logBuffer.filter(e => e.level === 'error').length;
421
+ if (clear) logBuffer.length = 0;
422
+ ack && ack({ ok: true, logs, total, errors, now: Date.now() });
423
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
424
+ });
425
+
426
+ socket.on('debug:screenshot', async ({ format = 'png', quality } = {}, ack) => {
427
+ try {
428
+ // Native chrome.tabs.captureVisibleTab via background SW —
429
+ // viewport-only screenshot of the active tab in this window.
430
+ // No canvas rasterization, handles cross-origin content cleanly.
431
+ if (typeof window.vetgoCaptureScreenshot !== 'function') {
432
+ return ack && ack({ ok: false, error: 'extension_not_loaded' });
433
+ }
434
+ const dataUrl = await window.vetgoCaptureScreenshot({ format, quality });
435
+ ack && ack({ ok: true, dataUrl, source: 'chrome.tabs.captureVisibleTab' });
436
+ } catch (e) { ack && ack({ ok: false, error: e.message }); }
437
+ });
438
+
439
+ socket.on('disconnect', (reason) => {
440
+ console.log(`[TaskWorker] Disconnected: ${reason}`);
441
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
442
+ if (emailAbort) { emailAbort.abort(); emailAbort = null; }
443
+ });
444
+
445
+ socket.on('connect_error', (err) => {
446
+ console.warn('[TaskWorker] Connect error:', err?.message);
447
+ });
448
+ }
@@ -19,6 +19,7 @@ import { initGitPanel } from './features/git-panel.js';
19
19
  import { initCommandsPanel } from './features/commands-panel.js';
20
20
  import { initBrowserPanel } from './features/browser-panel.js';
21
21
  import { initAgentPanel } from './features/agent-panel.js';
22
+ import { initTaskWorker } from './features/task-worker.js';
22
23
 
23
24
  export async function initMain() {
24
25
  console.log('VG Coder: Starting Main Logic...');
@@ -55,6 +56,9 @@ export async function initMain() {
55
56
  // Initialize keyboard shortcuts (global hotkeys for Shadow DOM)
56
57
  initKeyboardShortcuts();
57
58
 
59
+ // Remote task worker — only activates on aistudio.google.com
60
+ initTaskWorker();
61
+
58
62
  console.log('✅ VG Coder: Initialization Complete');
59
63
  } catch (e) {
60
64
  console.error('VG Coder Init Failed:', e);