agentgui 1.0.208 → 1.0.210

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/CLAUDE.md CHANGED
@@ -89,3 +89,53 @@ Server broadcasts:
89
89
  - `streaming_complete` - Execution finished
90
90
  - `streaming_error` - Execution failed
91
91
  - `conversation_created`, `conversation_updated`, `conversation_deleted`
92
+ - `tts_setup_progress` - Windows pocket-tts setup progress (step, status, message)
93
+
94
+ ## Pocket-TTS Windows Setup (Reliability for Slow/Bad Internet)
95
+
96
+ On Windows, text-to-speech uses pocket-tts which requires Python and pip install. The setup process is now resilient to slow/unreliable connections:
97
+
98
+ ### Features
99
+ - **Extended timeouts**: 120s for pip install (accommodates slow connections)
100
+ - **Retry logic**: 3 attempts with exponential backoff (1s, 2s delays)
101
+ - **Progress reporting**: Real-time updates via WebSocket to UI
102
+ - **Partial install cleanup**: Failed venvs are removed to allow retry
103
+ - **Installation verification**: Binary validation via `--version` check
104
+ - **Concurrent waiting**: Multiple simultaneous requests wait for single setup (600s timeout)
105
+
106
+ ### Configuration (lib/windows-pocket-tts-setup.js)
107
+ ```javascript
108
+ const CONFIG = {
109
+ PIP_TIMEOUT: 120000, // 2 minutes
110
+ VENV_CREATION_TIMEOUT: 30000, // 30 seconds
111
+ MAX_RETRIES: 3, // 3 attempts
112
+ RETRY_DELAY_MS: 1000, // 1 second initial
113
+ RETRY_BACKOFF_MULTIPLIER: 2, // 2x exponential
114
+ };
115
+ ```
116
+
117
+ ### Network Requirements
118
+ - **Minimum**: 50 kbps sustained, < 5s latency, < 10% packet loss
119
+ - **Recommended**: 256+ kbps, < 2s latency, < 1% packet loss
120
+ - **Expected time on slow connection**: 2-6 minutes with retries
121
+
122
+ ### Progress Messages
123
+ During TTS setup on first use, WebSocket broadcasts:
124
+ ```json
125
+ {
126
+ "type": "tts_setup_progress",
127
+ "step": "detecting-python|creating-venv|installing|verifying",
128
+ "status": "in-progress|success|error",
129
+ "message": "descriptive status message with retry count if applicable"
130
+ }
131
+ ```
132
+
133
+ ### Recovery Behavior
134
+ 1. Network timeout → auto-retry with backoff
135
+ 2. Partial venv → auto-cleanup before retry
136
+ 3. Failed verification → auto-cleanup and error
137
+ 4. Concurrent requests → first starts setup, others wait up to 600s
138
+ 5. Interrupted setup → cleanup allows fresh retry
139
+
140
+ ### Testing
141
+ Setup validates by running pocket-tts binary with `--version` flag to confirm functional installation, not just file existence.
@@ -8,6 +8,14 @@ const VENV_DIR = path.join(os.homedir(), '.gmgui', 'pocket-venv');
8
8
  const isWin = process.platform === 'win32';
9
9
  const EXECUTABLE_NAME = isWin ? 'pocket-tts.exe' : 'pocket-tts';
10
10
 
11
+ const CONFIG = {
12
+ PIP_TIMEOUT: 120000,
13
+ VENV_CREATION_TIMEOUT: 30000,
14
+ MAX_RETRIES: 3,
15
+ RETRY_DELAY_MS: 1000,
16
+ RETRY_BACKOFF_MULTIPLIER: 2,
17
+ };
18
+
11
19
  function getPocketTtsPath() {
12
20
  if (isWin) {
13
21
  return path.join(VENV_DIR, 'Scripts', EXECUTABLE_NAME);
@@ -17,7 +25,7 @@ function getPocketTtsPath() {
17
25
 
18
26
  function detectPython() {
19
27
  try {
20
- const versionOutput = execSync('python --version', { encoding: 'utf-8' }).trim();
28
+ const versionOutput = execSync('python --version', { encoding: 'utf-8', timeout: 10000 }).trim();
21
29
  const match = versionOutput.match(/(\d+)\.(\d+)/);
22
30
  if (!match) return { found: false, version: null, error: 'Could not parse version' };
23
31
 
@@ -40,6 +48,53 @@ function isSetup() {
40
48
  return fs.existsSync(exePath);
41
49
  }
42
50
 
51
+ function cleanupPartialInstall() {
52
+ try {
53
+ if (fs.existsSync(VENV_DIR)) {
54
+ fs.rmSync(VENV_DIR, { recursive: true, force: true });
55
+ return true;
56
+ }
57
+ } catch (e) {
58
+ console.error(`Failed to cleanup partial install: ${e.message}`);
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function verifyInstallation() {
64
+ const exePath = getPocketTtsPath();
65
+ if (!fs.existsSync(exePath)) {
66
+ return { valid: false, error: `Binary not found at ${exePath}` };
67
+ }
68
+
69
+ try {
70
+ const versionOutput = execSync(`"${exePath}" --version`, { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' });
71
+ return { valid: true, version: versionOutput.trim() };
72
+ } catch (e) {
73
+ return { valid: false, error: `Binary exists but failed verification: ${e.message}` };
74
+ }
75
+ }
76
+
77
+ async function executeWithRetry(fn, stepName, maxRetries = CONFIG.MAX_RETRIES) {
78
+ let lastError = null;
79
+ let delayMs = CONFIG.RETRY_DELAY_MS;
80
+
81
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ return await fn(attempt);
84
+ } catch (e) {
85
+ lastError = e;
86
+ if (attempt < maxRetries) {
87
+ console.log(`Attempt ${attempt}/${maxRetries} failed for ${stepName}, retrying in ${delayMs}ms`);
88
+ await new Promise(r => setTimeout(r, delayMs));
89
+ delayMs *= CONFIG.RETRY_BACKOFF_MULTIPLIER;
90
+ }
91
+ }
92
+ }
93
+
94
+ const msg = `${stepName} failed after ${maxRetries} attempts: ${lastError.message || lastError}`;
95
+ throw new Error(msg);
96
+ }
97
+
43
98
  async function install(onProgress) {
44
99
  const pythonDetect = detectPython();
45
100
 
@@ -57,56 +112,79 @@ async function install(onProgress) {
57
112
  if (onProgress) onProgress({ step: 'detecting-python', status: 'success', message: `Found Python ${pythonDetect.version}` });
58
113
 
59
114
  if (isSetup()) {
60
- if (onProgress) onProgress({ step: 'verifying', status: 'success', message: 'pocket-tts already installed' });
61
- return { success: true };
115
+ const verify = verifyInstallation();
116
+ if (verify.valid) {
117
+ if (onProgress) onProgress({ step: 'verifying', status: 'success', message: 'pocket-tts already installed' });
118
+ return { success: true };
119
+ }
62
120
  }
63
121
 
64
122
  if (onProgress) onProgress({ step: 'creating-venv', status: 'in-progress', message: `Creating virtual environment at ${VENV_DIR}` });
65
123
 
66
124
  try {
67
- const mkdirResult = execSync(`python -m venv "${VENV_DIR}"`, { encoding: 'utf-8', stdio: 'pipe' });
125
+ await executeWithRetry(async (attempt) => {
126
+ return execSync(`python -m venv "${VENV_DIR}"`, {
127
+ encoding: 'utf-8',
128
+ stdio: 'pipe',
129
+ timeout: CONFIG.VENV_CREATION_TIMEOUT,
130
+ });
131
+ }, 'venv creation', 2);
132
+
68
133
  if (onProgress) onProgress({ step: 'creating-venv', status: 'success', message: 'Virtual environment created' });
69
134
  } catch (e) {
70
- const msg = `Failed to create venv: ${e.message || e.stderr || e}`;
135
+ const msg = `Failed to create venv: ${e.message || e}`;
71
136
  if (onProgress) onProgress({ step: 'creating-venv', status: 'error', message: msg });
137
+ cleanupPartialInstall();
72
138
  return { success: false, error: msg };
73
139
  }
74
140
 
75
- if (onProgress) onProgress({ step: 'installing', status: 'in-progress', message: 'Installing pocket-tts via pip (this may take a minute)' });
141
+ if (onProgress) onProgress({ step: 'installing', status: 'in-progress', message: 'Installing pocket-tts via pip (this may take 2-5 minutes on slow connections)' });
76
142
 
77
143
  try {
78
- const pipCmd = isWin
79
- ? `"${path.join(VENV_DIR, 'Scripts', 'pip')}" install pocket-tts`
80
- : `"${path.join(VENV_DIR, 'bin', 'pip')}" install pocket-tts`;
144
+ await executeWithRetry(async (attempt) => {
145
+ if (attempt > 1 && onProgress) {
146
+ onProgress({ step: 'installing', status: 'in-progress', message: `Installing pocket-tts (attempt ${attempt}/${CONFIG.MAX_RETRIES})` });
147
+ }
148
+
149
+ const pipCmd = isWin
150
+ ? `"${path.join(VENV_DIR, 'Scripts', 'pip')}" install --no-cache-dir pocket-tts`
151
+ : `"${path.join(VENV_DIR, 'bin', 'pip')}" install --no-cache-dir pocket-tts`;
152
+
153
+ return execSync(pipCmd, {
154
+ encoding: 'utf-8',
155
+ stdio: 'pipe',
156
+ timeout: CONFIG.PIP_TIMEOUT,
157
+ env: { ...process.env, PIP_DEFAULT_TIMEOUT: '120' },
158
+ });
159
+ }, 'pip install', CONFIG.MAX_RETRIES);
81
160
 
82
- const installResult = execSync(pipCmd, { encoding: 'utf-8', stdio: 'pipe', timeout: 300000 });
83
161
  if (onProgress) onProgress({ step: 'installing', status: 'success', message: 'pocket-tts installed successfully' });
84
162
  } catch (e) {
85
- const msg = `Failed to install pocket-tts: ${e.message || e.stderr || e}`;
163
+ const msg = `Failed to install pocket-tts: ${e.message || e}`;
86
164
  if (onProgress) onProgress({ step: 'installing', status: 'error', message: msg });
165
+ cleanupPartialInstall();
87
166
  return { success: false, error: msg };
88
167
  }
89
168
 
90
169
  if (onProgress) onProgress({ step: 'verifying', status: 'in-progress', message: 'Verifying installation' });
91
170
 
92
- const exePath = getPocketTtsPath();
93
- const binDir = path.join(VENV_DIR, 'bin');
94
- const binExePath = path.join(binDir, 'pocket-tts');
95
-
96
- if (!fs.existsSync(exePath)) {
97
- const msg = `pocket-tts binary not found at ${exePath}`;
171
+ const verify = verifyInstallation();
172
+ if (!verify.valid) {
173
+ const msg = verify.error || 'Installation verification failed';
98
174
  if (onProgress) onProgress({ step: 'verifying', status: 'error', message: msg });
175
+ cleanupPartialInstall();
99
176
  return { success: false, error: msg };
100
177
  }
101
178
 
102
- // On Windows, webtalk looks for pocket-tts in bin/ (Unix path structure)
103
- // Copy the executable there for compatibility with Node.js spawn()
179
+ const exePath = getPocketTtsPath();
180
+ const binDir = path.join(VENV_DIR, 'bin');
181
+ const binExePath = path.join(binDir, 'pocket-tts');
182
+
104
183
  if (isWin) {
105
184
  try {
106
185
  fs.mkdirSync(binDir, { recursive: true });
107
186
  } catch (e) {}
108
187
 
109
- // Copy pocket-tts.exe to bin folder
110
188
  const exeWithExt = path.join(binDir, 'pocket-tts.exe');
111
189
  if (fs.existsSync(exePath) && !fs.existsSync(exeWithExt)) {
112
190
  try {
@@ -114,7 +192,6 @@ async function install(onProgress) {
114
192
  } catch (e) {}
115
193
  }
116
194
 
117
- // Create a batch file wrapper for Node.js spawn compatibility
118
195
  const batchFile = path.join(binDir, 'pocket-tts.bat');
119
196
  if (!fs.existsSync(batchFile) && fs.existsSync(exeWithExt)) {
120
197
  try {
@@ -124,9 +201,9 @@ async function install(onProgress) {
124
201
  }
125
202
  }
126
203
 
127
- if (onProgress) onProgress({ step: 'verifying', status: 'success', message: 'pocket-tts ready' });
204
+ if (onProgress) onProgress({ step: 'verifying', status: 'success', message: `pocket-tts ready (${verify.version})` });
128
205
 
129
206
  return { success: true };
130
207
  }
131
208
 
132
- export { detectPython, isSetup, install, getPocketTtsPath, VENV_DIR };
209
+ export { detectPython, isSetup, install, getPocketTtsPath, VENV_DIR, CONFIG, cleanupPartialInstall, verifyInstallation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.208",
3
+ "version": "1.0.210",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -27,7 +27,8 @@ async function ensurePocketTtsSetup(onProgress) {
27
27
 
28
28
  if (pocketTtsSetupState.inProgress) {
29
29
  let waited = 0;
30
- while (pocketTtsSetupState.inProgress && waited < 30000) {
30
+ const MAX_WAIT = 600000;
31
+ while (pocketTtsSetupState.inProgress && waited < MAX_WAIT) {
31
32
  await new Promise(r => setTimeout(r, 100));
32
33
  waited += 100;
33
34
  }
@@ -332,11 +333,11 @@ function geminiOAuthResultPage(title, message, success) {
332
333
  </div></body></html>`;
333
334
  }
334
335
 
335
- async function startGeminiOAuth(baseUrl) {
336
+ async function startGeminiOAuth() {
336
337
  const creds = getGeminiOAuthCreds();
337
338
  if (!creds) throw new Error('Could not find Gemini CLI OAuth credentials. Install gemini CLI first.');
338
339
 
339
- const redirectUri = `${baseUrl}${BASE_URL}/oauth2callback`;
340
+ const redirectUri = `http://localhost:${PORT}${BASE_URL}/oauth2callback`;
340
341
  const state = crypto.randomBytes(32).toString('hex');
341
342
 
342
343
  const client = new OAuth2Client({
@@ -364,71 +365,74 @@ async function startGeminiOAuth(baseUrl) {
364
365
  return authUrl;
365
366
  }
366
367
 
367
- async function handleGeminiOAuthCallback(req, res) {
368
- const reqUrl = new URL(req.url, buildBaseUrl(req));
369
-
370
- if (!geminiOAuthPending) {
371
- res.writeHead(200, { 'Content-Type': 'text/html' });
372
- res.end(geminiOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
373
- return;
374
- }
375
-
376
- const error = reqUrl.searchParams.get('error');
377
- if (error) {
378
- const desc = reqUrl.searchParams.get('error_description') || error;
379
- geminiOAuthState = { status: 'error', error: desc, email: null };
380
- geminiOAuthPending = null;
381
- res.writeHead(200, { 'Content-Type': 'text/html' });
382
- res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
383
- return;
384
- }
368
+ async function exchangeGeminiOAuthCode(code, state) {
369
+ if (!geminiOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
385
370
 
386
371
  const { client, redirectUri, state: expectedState } = geminiOAuthPending;
387
372
 
388
- if (reqUrl.searchParams.get('state') !== expectedState) {
373
+ if (state !== expectedState) {
389
374
  geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
390
375
  geminiOAuthPending = null;
391
- res.writeHead(200, { 'Content-Type': 'text/html' });
392
- res.end(geminiOAuthResultPage('Authentication Failed', 'State mismatch.', false));
393
- return;
376
+ throw new Error('State mismatch - possible CSRF attack.');
394
377
  }
395
378
 
396
- const code = reqUrl.searchParams.get('code');
397
379
  if (!code) {
398
- geminiOAuthState = { status: 'error', error: 'No authorization code', email: null };
380
+ geminiOAuthState = { status: 'error', error: 'No authorization code received', email: null };
399
381
  geminiOAuthPending = null;
382
+ throw new Error('No authorization code received.');
383
+ }
384
+
385
+ const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
386
+ client.setCredentials(tokens);
387
+
388
+ let email = '';
389
+ try {
390
+ const { token } = await client.getAccessToken();
391
+ if (token) {
392
+ const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
393
+ headers: { Authorization: `Bearer ${token}` }
394
+ });
395
+ if (resp.ok) {
396
+ const info = await resp.json();
397
+ email = info.email || '';
398
+ }
399
+ }
400
+ } catch (_) {}
401
+
402
+ saveGeminiCredentials(tokens, email);
403
+ geminiOAuthState = { status: 'success', error: null, email };
404
+ geminiOAuthPending = null;
405
+
406
+ return email;
407
+ }
408
+
409
+ async function handleGeminiOAuthCallback(req, res) {
410
+ const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
411
+
412
+ if (!geminiOAuthPending) {
400
413
  res.writeHead(200, { 'Content-Type': 'text/html' });
401
- res.end(geminiOAuthResultPage('Authentication Failed', 'No authorization code received.', false));
414
+ res.end(geminiOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
402
415
  return;
403
416
  }
404
417
 
405
418
  try {
406
- const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
407
- client.setCredentials(tokens);
408
-
409
- let email = '';
410
- try {
411
- const { token } = await client.getAccessToken();
412
- if (token) {
413
- const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
414
- headers: { Authorization: `Bearer ${token}` }
415
- });
416
- if (resp.ok) {
417
- const info = await resp.json();
418
- email = info.email || '';
419
- }
420
- }
421
- } catch (_) {}
419
+ const error = reqUrl.searchParams.get('error');
420
+ if (error) {
421
+ const desc = reqUrl.searchParams.get('error_description') || error;
422
+ geminiOAuthState = { status: 'error', error: desc, email: null };
423
+ geminiOAuthPending = null;
424
+ res.writeHead(200, { 'Content-Type': 'text/html' });
425
+ res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
426
+ return;
427
+ }
422
428
 
423
- saveGeminiCredentials(tokens, email);
424
- geminiOAuthState = { status: 'success', error: null, email };
425
- geminiOAuthPending = null;
429
+ const code = reqUrl.searchParams.get('code');
430
+ const state = reqUrl.searchParams.get('state');
431
+ const email = await exchangeGeminiOAuthCode(code, state);
426
432
 
427
433
  res.writeHead(200, { 'Content-Type': 'text/html' });
428
434
  res.end(geminiOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
429
435
  } catch (e) {
430
- geminiOAuthState = { status: 'error', error: e.message, email: null };
431
- geminiOAuthPending = null;
432
436
  res.writeHead(200, { 'Content-Type': 'text/html' });
433
437
  res.end(geminiOAuthResultPage('Authentication Failed', e.message, false));
434
438
  }
@@ -1126,7 +1130,7 @@ const server = http.createServer(async (req, res) => {
1126
1130
 
1127
1131
  if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
1128
1132
  try {
1129
- const authUrl = await startGeminiOAuth(buildBaseUrl(req));
1133
+ const authUrl = await startGeminiOAuth();
1130
1134
  sendJSON(req, res, 200, { authUrl });
1131
1135
  } catch (e) {
1132
1136
  console.error('[gemini-oauth] /api/gemini-oauth/start failed:', e);
@@ -1140,6 +1144,42 @@ const server = http.createServer(async (req, res) => {
1140
1144
  return;
1141
1145
  }
1142
1146
 
1147
+ if (pathOnly === '/api/gemini-oauth/complete' && req.method === 'POST') {
1148
+ try {
1149
+ const body = await parseBody(req);
1150
+ const pastedUrl = (body.url || '').trim();
1151
+ if (!pastedUrl) {
1152
+ sendJSON(req, res, 400, { error: 'No URL provided' });
1153
+ return;
1154
+ }
1155
+
1156
+ let parsed;
1157
+ try { parsed = new URL(pastedUrl); } catch (_) {
1158
+ sendJSON(req, res, 400, { error: 'Invalid URL. Paste the full URL from the browser address bar.' });
1159
+ return;
1160
+ }
1161
+
1162
+ const error = parsed.searchParams.get('error');
1163
+ if (error) {
1164
+ const desc = parsed.searchParams.get('error_description') || error;
1165
+ geminiOAuthState = { status: 'error', error: desc, email: null };
1166
+ geminiOAuthPending = null;
1167
+ sendJSON(req, res, 200, { error: desc });
1168
+ return;
1169
+ }
1170
+
1171
+ const code = parsed.searchParams.get('code');
1172
+ const state = parsed.searchParams.get('state');
1173
+ const email = await exchangeGeminiOAuthCode(code, state);
1174
+ sendJSON(req, res, 200, { success: true, email });
1175
+ } catch (e) {
1176
+ geminiOAuthState = { status: 'error', error: e.message, email: null };
1177
+ geminiOAuthPending = null;
1178
+ sendJSON(req, res, 400, { error: e.message });
1179
+ }
1180
+ return;
1181
+ }
1182
+
1143
1183
  const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
1144
1184
  if (agentAuthMatch && req.method === 'POST') {
1145
1185
  const agentId = agentAuthMatch[1];
@@ -1148,7 +1188,7 @@ const server = http.createServer(async (req, res) => {
1148
1188
 
1149
1189
  if (agentId === 'gemini') {
1150
1190
  try {
1151
- const authUrl = await startGeminiOAuth(buildBaseUrl(req));
1191
+ const authUrl = await startGeminiOAuth();
1152
1192
  const conversationId = '__agent_auth__';
1153
1193
  broadcastSync({ type: 'script_started', conversationId, script: 'auth-gemini', agentId: 'gemini', timestamp: Date.now() });
1154
1194
  broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening Google OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
@@ -130,6 +130,81 @@
130
130
 
131
131
  function closeDropdown() { dropdown.classList.remove('open'); editingProvider = null; }
132
132
 
133
+ var oauthPollInterval = null, oauthPollTimeout = null;
134
+
135
+ function cleanupOAuthPolling() {
136
+ if (oauthPollInterval) { clearInterval(oauthPollInterval); oauthPollInterval = null; }
137
+ if (oauthPollTimeout) { clearTimeout(oauthPollTimeout); oauthPollTimeout = null; }
138
+ }
139
+
140
+ function showOAuthPasteModal() {
141
+ removeOAuthPasteModal();
142
+ var overlay = document.createElement('div');
143
+ overlay.id = 'oauthPasteModal';
144
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999;';
145
+ var s = function(c) { return 'font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin:0 0 ' + (c ? '0' : '0.5rem') + ';'; };
146
+ overlay.innerHTML = '<div style="background:var(--color-bg-secondary,#1f2937);border-radius:1rem;padding:2rem;max-width:28rem;width:calc(100% - 2rem);box-shadow:0 25px 50px rgba(0,0,0,0.5);color:var(--color-text-primary,white);font-family:system-ui,sans-serif;" onclick="event.stopPropagation()">' +
147
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">' +
148
+ '<h2 style="font-size:1.125rem;font-weight:700;margin:0;">Complete Google Sign-In</h2>' +
149
+ '<button id="oauthPasteClose" style="background:none;border:none;color:var(--color-text-secondary,#9ca3af);font-size:1.5rem;cursor:pointer;padding:0;line-height:1;">\u00d7</button></div>' +
150
+ '<div style="margin-bottom:1rem;padding:1rem;background:var(--color-bg-tertiary,rgba(255,255,255,0.05));border-radius:0.5rem;">' +
151
+ '<p style="' + s() + '">1. A Google sign-in page has opened in a new tab.</p>' +
152
+ '<p style="' + s() + '">2. Complete the sign-in process with Google.</p>' +
153
+ '<p style="' + s() + '">3. After signing in, you will be redirected to a page that <span style="color:#facc15;font-weight:600;">may not load</span> (this is expected).</p>' +
154
+ '<p style="' + s(1) + '">4. Copy the <span style="color:white;font-weight:600;">entire URL</span> from the address bar and paste it below.</p></div>' +
155
+ '<label style="display:block;font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin-bottom:0.5rem;">Paste the redirect URL here:</label>' +
156
+ '<input type="text" id="oauthPasteInput" placeholder="http://localhost:3000/gm/oauth2callback?code=..." style="width:100%;box-sizing:border-box;padding:0.75rem 1rem;background:var(--color-bg-primary,#374151);border:1px solid var(--color-border,#4b5563);border-radius:0.5rem;color:var(--color-text-primary,white);font-size:0.8rem;font-family:monospace;outline:none;" />' +
157
+ '<p id="oauthPasteError" style="font-size:0.75rem;color:#ef4444;margin:0.5rem 0 0;display:none;"></p>' +
158
+ '<div style="display:flex;gap:0.75rem;margin-top:1.25rem;">' +
159
+ '<button id="oauthPasteCancel" style="flex:1;padding:0.625rem;border-radius:0.5rem;border:1px solid var(--color-border,#4b5563);background:transparent;color:var(--color-text-primary,white);font-size:0.8rem;cursor:pointer;font-weight:600;">Cancel</button>' +
160
+ '<button id="oauthPasteSubmit" style="flex:1;padding:0.625rem;border-radius:0.5rem;border:none;background:var(--color-primary,#3b82f6);color:white;font-size:0.8rem;cursor:pointer;font-weight:600;">Complete Sign-In</button></div>' +
161
+ '<p style="font-size:0.7rem;color:var(--color-text-secondary,#6b7280);margin-top:1rem;text-align:center;">If the redirect page loaded successfully, this dialog will close automatically.</p></div>';
162
+ document.body.appendChild(overlay);
163
+ var dismiss = function() { cleanupOAuthPolling(); authRunning = false; removeOAuthPasteModal(); };
164
+ document.getElementById('oauthPasteClose').addEventListener('click', dismiss);
165
+ document.getElementById('oauthPasteCancel').addEventListener('click', dismiss);
166
+ document.getElementById('oauthPasteSubmit').addEventListener('click', submitOAuthPasteUrl);
167
+ document.getElementById('oauthPasteInput').addEventListener('keydown', function(e) { if (e.key === 'Enter') submitOAuthPasteUrl(); });
168
+ setTimeout(function() { var i = document.getElementById('oauthPasteInput'); if (i) i.focus(); }, 100);
169
+ }
170
+
171
+ function removeOAuthPasteModal() {
172
+ var el = document.getElementById('oauthPasteModal');
173
+ if (el) el.remove();
174
+ }
175
+
176
+ function submitOAuthPasteUrl() {
177
+ var input = document.getElementById('oauthPasteInput');
178
+ var errorEl = document.getElementById('oauthPasteError');
179
+ var submitBtn = document.getElementById('oauthPasteSubmit');
180
+ if (!input) return;
181
+ var url = input.value.trim();
182
+ if (!url) {
183
+ if (errorEl) { errorEl.textContent = 'Please paste the URL from the redirected page.'; errorEl.style.display = 'block'; }
184
+ return;
185
+ }
186
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; }
187
+ if (errorEl) errorEl.style.display = 'none';
188
+
189
+ fetch(BASE + '/api/gemini-oauth/complete', {
190
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({ url: url })
192
+ }).then(function(r) { return r.json(); }).then(function(data) {
193
+ if (data.success) {
194
+ cleanupOAuthPolling();
195
+ authRunning = false;
196
+ removeOAuthPasteModal();
197
+ refresh();
198
+ } else {
199
+ if (errorEl) { errorEl.textContent = data.error || 'Failed to complete authentication.'; errorEl.style.display = 'block'; }
200
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Complete Sign-In'; }
201
+ }
202
+ }).catch(function(e) {
203
+ if (errorEl) { errorEl.textContent = e.message; errorEl.style.display = 'block'; }
204
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Complete Sign-In'; }
205
+ });
206
+ }
207
+
133
208
  function triggerAuth(agentId) {
134
209
  if (authRunning) return;
135
210
  fetch(BASE + '/api/agents/' + agentId + '/auth', {
@@ -141,6 +216,28 @@
141
216
  if (term) { term.clear(); term.writeln('\x1b[36m[authenticating ' + agentId + ']\x1b[0m\r\n'); }
142
217
  if (data.authUrl) {
143
218
  window.open(data.authUrl, '_blank');
219
+ if (agentId === 'gemini') {
220
+ showOAuthPasteModal();
221
+ cleanupOAuthPolling();
222
+ oauthPollInterval = setInterval(function() {
223
+ fetch(BASE + '/api/gemini-oauth/status').then(function(r) { return r.json(); }).then(function(status) {
224
+ if (status.status === 'success') {
225
+ cleanupOAuthPolling();
226
+ authRunning = false;
227
+ removeOAuthPasteModal();
228
+ refresh();
229
+ } else if (status.status === 'error') {
230
+ cleanupOAuthPolling();
231
+ authRunning = false;
232
+ removeOAuthPasteModal();
233
+ }
234
+ }).catch(function() {});
235
+ }, 1500);
236
+ oauthPollTimeout = setTimeout(function() {
237
+ cleanupOAuthPolling();
238
+ if (authRunning) { authRunning = false; removeOAuthPasteModal(); }
239
+ }, 5 * 60 * 1000);
240
+ }
144
241
  }
145
242
  }
146
243
  }).catch(function() {});
@@ -159,6 +256,8 @@
159
256
  if (term) term.write(data.data);
160
257
  } else if (data.type === 'script_stopped') {
161
258
  authRunning = false;
259
+ removeOAuthPasteModal();
260
+ cleanupOAuthPolling();
162
261
  var term = getTerminal();
163
262
  var msg = data.error ? data.error : ('exited with code ' + (data.code || 0));
164
263
  if (term) term.writeln('\r\n\x1b[90m[auth ' + msg + ']\x1b[0m');