agentgui 1.0.211 → 1.0.213

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/database.js CHANGED
@@ -1120,6 +1120,24 @@ export const queries = {
1120
1120
  });
1121
1121
  },
1122
1122
 
1123
+ getChunksSinceSeq(sessionId, sinceSeq) {
1124
+ const stmt = prep(
1125
+ `SELECT id, sessionId, conversationId, sequence, type, data, created_at
1126
+ FROM chunks WHERE sessionId = ? AND sequence > ? ORDER BY sequence ASC`
1127
+ );
1128
+ const rows = stmt.all(sessionId, sinceSeq);
1129
+ return rows.map(row => {
1130
+ try {
1131
+ return {
1132
+ ...row,
1133
+ data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data
1134
+ };
1135
+ } catch (e) {
1136
+ return row;
1137
+ }
1138
+ });
1139
+ },
1140
+
1123
1141
  deleteSessionChunks(sessionId) {
1124
1142
  const stmt = prep('DELETE FROM chunks WHERE sessionId = ?');
1125
1143
  const result = stmt.run(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.211",
3
+ "version": "1.0.213",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -336,6 +336,67 @@ function geminiOAuthResultPage(title, message, success) {
336
336
  </div></body></html>`;
337
337
  }
338
338
 
339
+ function encodeOAuthState(csrfToken, relayUrl) {
340
+ const payload = JSON.stringify({ t: csrfToken, r: relayUrl });
341
+ return Buffer.from(payload).toString('base64url');
342
+ }
343
+
344
+ function decodeOAuthState(stateStr) {
345
+ try {
346
+ const payload = JSON.parse(Buffer.from(stateStr, 'base64url').toString());
347
+ return { csrfToken: payload.t, relayUrl: payload.r };
348
+ } catch (_) {
349
+ return { csrfToken: stateStr, relayUrl: null };
350
+ }
351
+ }
352
+
353
+ function geminiOAuthRelayPage(code, state, error) {
354
+ const stateData = decodeOAuthState(state || '');
355
+ const relayUrl = stateData.relayUrl || '';
356
+ const escapedCode = (code || '').replace(/['"\\]/g, '');
357
+ const escapedState = (state || '').replace(/['"\\]/g, '');
358
+ const escapedError = (error || '').replace(/['"\\]/g, '');
359
+ const escapedRelay = relayUrl.replace(/['"\\]/g, '');
360
+ return `<!DOCTYPE html><html><head><title>Completing sign-in...</title></head>
361
+ <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111827;font-family:system-ui,sans-serif;color:white;">
362
+ <div id="status" style="text-align:center;max-width:400px;padding:2rem;">
363
+ <div id="spinner" style="font-size:2rem;margin-bottom:1rem;">&#8987;</div>
364
+ <h1 id="title" style="font-size:1.5rem;margin-bottom:0.5rem;">Completing sign-in...</h1>
365
+ <p id="msg" style="color:#9ca3af;">Relaying authentication to server...</p>
366
+ </div>
367
+ <script>
368
+ (function() {
369
+ var code = '${escapedCode}';
370
+ var state = '${escapedState}';
371
+ var error = '${escapedError}';
372
+ var relayUrl = '${escapedRelay}';
373
+ function show(icon, title, msg, color) {
374
+ document.getElementById('spinner').textContent = icon;
375
+ document.getElementById('spinner').style.color = color;
376
+ document.getElementById('title').textContent = title;
377
+ document.getElementById('msg').textContent = msg;
378
+ }
379
+ if (error) { show('\\u2717', 'Authentication Failed', error, '#ef4444'); return; }
380
+ if (!code) { show('\\u2717', 'Authentication Failed', 'No authorization code received.', '#ef4444'); return; }
381
+ if (!relayUrl) { show('\\u2713', 'Authentication Successful', 'Credentials saved. You can close this tab.', '#10b981'); return; }
382
+ fetch(relayUrl, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({ code: code, state: state })
386
+ }).then(function(r) { return r.json(); }).then(function(data) {
387
+ if (data.success) {
388
+ show('\\u2713', 'Authentication Successful', data.email ? 'Signed in as ' + data.email + '. You can close this tab.' : 'Credentials saved. You can close this tab.', '#10b981');
389
+ } else {
390
+ show('\\u2717', 'Authentication Failed', data.error || 'Unknown error', '#ef4444');
391
+ }
392
+ }).catch(function(e) {
393
+ show('\\u2717', 'Relay Failed', 'Could not reach server: ' + e.message + '. You may need to paste the URL manually.', '#ef4444');
394
+ });
395
+ })();
396
+ </script>
397
+ </body></html>`;
398
+ }
399
+
339
400
  function isRemoteRequest(req) {
340
401
  return !!(req && (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['x-forwarded-proto']));
341
402
  }
@@ -353,7 +414,10 @@ async function startGeminiOAuth(req) {
353
414
  redirectUri = `http://localhost:${PORT}${BASE_URL}/oauth2callback`;
354
415
  }
355
416
 
356
- const state = crypto.randomBytes(32).toString('hex');
417
+ const csrfToken = crypto.randomBytes(32).toString('hex');
418
+ const relayUrl = req ? `${buildBaseUrl(req)}${BASE_URL}/api/gemini-oauth/relay` : null;
419
+ const state = encodeOAuthState(csrfToken, relayUrl);
420
+
357
421
  const client = new OAuth2Client({
358
422
  clientId: creds.clientId,
359
423
  clientSecret: creds.clientSecret,
@@ -367,7 +431,7 @@ async function startGeminiOAuth(req) {
367
431
  });
368
432
 
369
433
  const mode = useCustomClient ? 'custom' : (remote ? 'cli-remote' : 'cli-local');
370
- geminiOAuthPending = { client, redirectUri, state };
434
+ geminiOAuthPending = { client, redirectUri, state: csrfToken };
371
435
  geminiOAuthState = { status: 'pending', error: null, email: null };
372
436
 
373
437
  setTimeout(() => {
@@ -380,12 +444,13 @@ async function startGeminiOAuth(req) {
380
444
  return { authUrl, mode };
381
445
  }
382
446
 
383
- async function exchangeGeminiOAuthCode(code, state) {
447
+ async function exchangeGeminiOAuthCode(code, stateParam) {
384
448
  if (!geminiOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
385
449
 
386
- const { client, redirectUri, state: expectedState } = geminiOAuthPending;
450
+ const { client, redirectUri, state: expectedCsrf } = geminiOAuthPending;
451
+ const { csrfToken } = decodeOAuthState(stateParam);
387
452
 
388
- if (state !== expectedState) {
453
+ if (csrfToken !== expectedCsrf) {
389
454
  geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
390
455
  geminiOAuthPending = null;
391
456
  throw new Error('State mismatch - possible CSRF attack.');
@@ -423,6 +488,23 @@ async function exchangeGeminiOAuthCode(code, state) {
423
488
 
424
489
  async function handleGeminiOAuthCallback(req, res) {
425
490
  const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
491
+ const code = reqUrl.searchParams.get('code');
492
+ const state = reqUrl.searchParams.get('state');
493
+ const error = reqUrl.searchParams.get('error');
494
+ const errorDesc = reqUrl.searchParams.get('error_description');
495
+
496
+ if (error) {
497
+ const desc = errorDesc || error;
498
+ geminiOAuthState = { status: 'error', error: desc, email: null };
499
+ geminiOAuthPending = null;
500
+ }
501
+
502
+ const stateData = decodeOAuthState(state || '');
503
+ if (stateData.relayUrl) {
504
+ res.writeHead(200, { 'Content-Type': 'text/html' });
505
+ res.end(geminiOAuthRelayPage(code, state, errorDesc || error));
506
+ return;
507
+ }
426
508
 
427
509
  if (!geminiOAuthPending) {
428
510
  res.writeHead(200, { 'Content-Type': 'text/html' });
@@ -431,20 +513,8 @@ async function handleGeminiOAuthCallback(req, res) {
431
513
  }
432
514
 
433
515
  try {
434
- const error = reqUrl.searchParams.get('error');
435
- if (error) {
436
- const desc = reqUrl.searchParams.get('error_description') || error;
437
- geminiOAuthState = { status: 'error', error: desc, email: null };
438
- geminiOAuthPending = null;
439
- res.writeHead(200, { 'Content-Type': 'text/html' });
440
- res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
441
- return;
442
- }
443
-
444
- const code = reqUrl.searchParams.get('code');
445
- const state = reqUrl.searchParams.get('state');
516
+ if (error) throw new Error(errorDesc || error);
446
517
  const email = await exchangeGeminiOAuthCode(code, state);
447
-
448
518
  res.writeHead(200, { 'Content-Type': 'text/html' });
449
519
  res.end(geminiOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
450
520
  } catch (e) {
@@ -941,9 +1011,15 @@ const server = http.createServer(async (req, res) => {
941
1011
  if (!sess) { sendJSON(req, res, 404, { error: 'Session not found' }); return; }
942
1012
 
943
1013
  const url = new URL(req.url, 'http://localhost');
1014
+ const sinceSeq = parseInt(url.searchParams.get('sinceSeq') || '-1');
944
1015
  const since = parseInt(url.searchParams.get('since') || '0');
945
1016
 
946
- const chunks = queries.getChunksSince(sessionId, since);
1017
+ let chunks;
1018
+ if (sinceSeq >= 0) {
1019
+ chunks = queries.getChunksSinceSeq(sessionId, sinceSeq);
1020
+ } else {
1021
+ chunks = queries.getChunksSince(sessionId, since);
1022
+ }
947
1023
  sendJSON(req, res, 200, { ok: true, chunks });
948
1024
  return;
949
1025
  }
@@ -1159,6 +1235,24 @@ const server = http.createServer(async (req, res) => {
1159
1235
  return;
1160
1236
  }
1161
1237
 
1238
+ if (pathOnly === '/api/gemini-oauth/relay' && req.method === 'POST') {
1239
+ try {
1240
+ const body = await parseBody(req);
1241
+ const { code, state: stateParam } = body;
1242
+ if (!code || !stateParam) {
1243
+ sendJSON(req, res, 400, { error: 'Missing code or state' });
1244
+ return;
1245
+ }
1246
+ const email = await exchangeGeminiOAuthCode(code, stateParam);
1247
+ sendJSON(req, res, 200, { success: true, email });
1248
+ } catch (e) {
1249
+ geminiOAuthState = { status: 'error', error: e.message, email: null };
1250
+ geminiOAuthPending = null;
1251
+ sendJSON(req, res, 400, { error: e.message });
1252
+ }
1253
+ return;
1254
+ }
1255
+
1162
1256
  if (pathOnly === '/api/gemini-oauth/complete' && req.method === 'POST') {
1163
1257
  try {
1164
1258
  const body = await parseBody(req);
@@ -1711,6 +1805,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
1711
1805
  conversationId,
1712
1806
  block: systemBlock,
1713
1807
  blockIndex: allBlocks.length,
1808
+ seq: currentSequence,
1714
1809
  timestamp: Date.now()
1715
1810
  });
1716
1811
  } else if (parsed.type === 'assistant' && parsed.message?.content) {
@@ -1726,6 +1821,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
1726
1821
  conversationId,
1727
1822
  block,
1728
1823
  blockIndex: allBlocks.length - 1,
1824
+ seq: currentSequence,
1729
1825
  timestamp: Date.now()
1730
1826
  });
1731
1827
 
@@ -1752,6 +1848,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
1752
1848
  conversationId,
1753
1849
  block: toolResultBlock,
1754
1850
  blockIndex: allBlocks.length,
1851
+ seq: currentSequence,
1755
1852
  timestamp: Date.now()
1756
1853
  });
1757
1854
  }
@@ -1777,6 +1874,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
1777
1874
  block: resultBlock,
1778
1875
  blockIndex: allBlocks.length,
1779
1876
  isResult: true,
1877
+ seq: currentSequence,
1780
1878
  timestamp: Date.now()
1781
1879
  });
1782
1880
 
@@ -1827,6 +1925,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
1827
1925
  sessionId,
1828
1926
  conversationId,
1829
1927
  eventCount,
1928
+ seq: currentSequence,
1830
1929
  timestamp: Date.now()
1831
1930
  });
1832
1931
 
@@ -2066,9 +2165,13 @@ wss.on('connection', (ws, req) => {
2066
2165
  }));
2067
2166
  } else if (data.type === 'set_voice') {
2068
2167
  ws.ttsVoiceId = data.voiceId || 'default';
2168
+ } else if (data.type === 'latency_report') {
2169
+ ws.latencyTier = data.quality || 'good';
2170
+ ws.latencyAvg = data.avg || 0;
2069
2171
  } else if (data.type === 'ping') {
2070
2172
  ws.send(JSON.stringify({
2071
2173
  type: 'pong',
2174
+ requestId: data.requestId,
2072
2175
  timestamp: Date.now()
2073
2176
  }));
2074
2177
  }
@@ -2097,13 +2200,16 @@ wss.on('connection', (ws, req) => {
2097
2200
  const BROADCAST_TYPES = new Set([
2098
2201
  'message_created', 'conversation_created', 'conversation_updated',
2099
2202
  'conversations_updated', 'conversation_deleted', 'queue_status', 'queue_updated',
2100
- 'streaming_start', 'streaming_complete', 'streaming_error',
2101
2203
  'rate_limit_hit', 'rate_limit_clear',
2102
2204
  'script_started', 'script_stopped', 'script_output'
2103
2205
  ]);
2104
2206
 
2105
2207
  const wsBatchQueues = new Map();
2106
- const WS_BATCH_INTERVAL = 16;
2208
+ const BATCH_BY_TIER = { excellent: 16, good: 32, fair: 50, poor: 100, bad: 200 };
2209
+
2210
+ function getBatchInterval(ws) {
2211
+ return BATCH_BY_TIER[ws.latencyTier] || 32;
2212
+ }
2107
2213
 
2108
2214
  function flushWsBatch(ws) {
2109
2215
  const queue = wsBatchQueues.get(ws);
@@ -2124,7 +2230,7 @@ function sendToClient(ws, data) {
2124
2230
  if (!queue) { queue = { msgs: [], timer: null }; wsBatchQueues.set(ws, queue); }
2125
2231
  queue.msgs.push(data);
2126
2232
  if (!queue.timer) {
2127
- queue.timer = setTimeout(() => flushWsBatch(ws), WS_BATCH_INTERVAL);
2233
+ queue.timer = setTimeout(() => flushWsBatch(ws), getBatchInterval(ws));
2128
2234
  }
2129
2235
  }
2130
2236
 
package/static/index.html CHANGED
@@ -2213,6 +2213,85 @@
2213
2213
  }
2214
2214
 
2215
2215
  html.dark .event-streaming-complete { background: linear-gradient(135deg, #0a1f0f, #0f2b1a); }
2216
+
2217
+ @keyframes skeleton-pulse {
2218
+ 0%, 100% { opacity: 0.4; }
2219
+ 50% { opacity: 0.15; }
2220
+ }
2221
+ .skeleton-pulse { animation: skeleton-pulse 1.5s ease-in-out infinite; }
2222
+ .skeleton-loading { padding: 1rem; }
2223
+
2224
+ .message-sending { opacity: 0.6; transition: opacity 0.3s ease; }
2225
+ .message-send-failed { border-left: 3px solid var(--color-error); }
2226
+
2227
+ .connection-indicator {
2228
+ display: inline-flex;
2229
+ align-items: center;
2230
+ gap: 0.375rem;
2231
+ padding: 0.25rem 0.5rem;
2232
+ border-radius: 1rem;
2233
+ font-size: 0.75rem;
2234
+ cursor: pointer;
2235
+ transition: all 0.3s ease;
2236
+ }
2237
+ .connection-dot {
2238
+ width: 8px;
2239
+ height: 8px;
2240
+ border-radius: 50%;
2241
+ transition: background-color 0.5s ease;
2242
+ }
2243
+ .connection-dot.excellent { background: #10b981; }
2244
+ .connection-dot.good { background: #10b981; }
2245
+ .connection-dot.fair { background: #f59e0b; }
2246
+ .connection-dot.poor { background: #f97316; }
2247
+ .connection-dot.bad { background: #ef4444; }
2248
+ .connection-dot.unknown { background: #6b7280; }
2249
+ .connection-dot.disconnected { background: #ef4444; animation: pulse 1.5s ease-in-out infinite; }
2250
+ .connection-dot.reconnecting { background: #f59e0b; animation: pulse 1s ease-in-out infinite; }
2251
+
2252
+ .connection-tooltip {
2253
+ position: absolute;
2254
+ top: 100%;
2255
+ right: 0;
2256
+ margin-top: 0.5rem;
2257
+ padding: 0.75rem;
2258
+ background: var(--color-bg-secondary);
2259
+ border: 1px solid var(--color-border);
2260
+ border-radius: 0.5rem;
2261
+ font-size: 0.75rem;
2262
+ white-space: nowrap;
2263
+ z-index: 100;
2264
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2265
+ }
2266
+
2267
+ .new-content-pill {
2268
+ position: sticky;
2269
+ bottom: 0.5rem;
2270
+ left: 50%;
2271
+ transform: translateX(-50%);
2272
+ padding: 0.375rem 1rem;
2273
+ background: var(--color-primary);
2274
+ color: white;
2275
+ border: none;
2276
+ border-radius: 1rem;
2277
+ font-size: 0.8rem;
2278
+ cursor: pointer;
2279
+ z-index: 50;
2280
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
2281
+ transition: opacity 0.2s ease;
2282
+ }
2283
+ .new-content-pill:hover { opacity: 0.9; }
2284
+
2285
+ @keyframes block-appear {
2286
+ from { opacity: 0; transform: translateY(6px); }
2287
+ to { opacity: 1; transform: translateY(0); }
2288
+ }
2289
+ .streaming-blocks > * {
2290
+ animation: block-appear 0.2s ease-out both;
2291
+ }
2292
+ .streaming-blocks > details.block-tool-use {
2293
+ transition: max-height 0.3s ease;
2294
+ }
2216
2295
  </style>
2217
2296
  </head>
2218
2297
  <body>
@@ -130,46 +130,61 @@
130
130
 
131
131
  function closeDropdown() { dropdown.classList.remove('open'); editingProvider = null; }
132
132
 
133
- var oauthPollInterval = null, oauthPollTimeout = null;
133
+ var oauthPollInterval = null, oauthPollTimeout = null, oauthFallbackTimer = null;
134
134
 
135
135
  function cleanupOAuthPolling() {
136
136
  if (oauthPollInterval) { clearInterval(oauthPollInterval); oauthPollInterval = null; }
137
137
  if (oauthPollTimeout) { clearTimeout(oauthPollTimeout); oauthPollTimeout = null; }
138
+ if (oauthFallbackTimer) { clearTimeout(oauthFallbackTimer); oauthFallbackTimer = null; }
138
139
  }
139
140
 
140
- function showOAuthPasteModal() {
141
- removeOAuthPasteModal();
141
+ function showOAuthWaitingModal() {
142
+ removeOAuthModal();
142
143
  var overlay = document.createElement('div');
143
- overlay.id = 'oauthPasteModal';
144
+ overlay.id = 'oauthWaitingModal';
144
145
  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
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
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>' +
148
+ '<h2 style="font-size:1.125rem;font-weight:700;margin:0;">Google Sign-In</h2>' +
149
+ '<button id="oauthWaitingClose" 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 id="oauthWaitingContent" style="text-align:center;padding:1.5rem 0;">' +
151
+ '<div style="font-size:2rem;margin-bottom:1rem;animation:pulse 2s infinite;">&#9203;</div>' +
152
+ '<p style="font-size:0.85rem;color:var(--color-text-secondary,#d1d5db);margin:0 0 0.5rem;">Waiting for Google sign-in to complete...</p>' +
153
+ '<p style="font-size:0.75rem;color:var(--color-text-secondary,#6b7280);margin:0;">Complete the sign-in in the tab that just opened.</p>' +
154
+ '<p style="font-size:0.75rem;color:var(--color-text-secondary,#6b7280);margin:0.25rem 0 0;">This dialog will close automatically when done.</p></div>' +
155
+ '<div id="oauthPasteFallback" style="display:none;">' +
150
156
  '<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>' +
157
+ '<p style="font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin:0 0 0.5rem;">The automatic relay did not complete. This can happen when accessing the server remotely.</p>' +
158
+ '<p style="font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin:0;">Copy the <span style="color:white;font-weight:600;">entire URL</span> from the sign-in tab and paste it below.</p></div>' +
156
159
  '<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>' +
160
+ '<p id="oauthPasteError" style="font-size:0.75rem;color:#ef4444;margin:0.5rem 0 0;display:none;"></p></div>' +
158
161
  '<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
+ '<button id="oauthWaitingCancel" 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>' +
163
+ '<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;display:none;">Complete Sign-In</button></div>' +
164
+ '<style>@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}</style></div>';
162
165
  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
+ var dismiss = function() { cleanupOAuthPolling(); authRunning = false; removeOAuthModal(); };
167
+ document.getElementById('oauthWaitingClose').addEventListener('click', dismiss);
168
+ document.getElementById('oauthWaitingCancel').addEventListener('click', dismiss);
166
169
  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
  }
170
171
 
171
- function removeOAuthPasteModal() {
172
- var el = document.getElementById('oauthPasteModal');
172
+ function showOAuthPasteFallback() {
173
+ var fallback = document.getElementById('oauthPasteFallback');
174
+ var waitContent = document.getElementById('oauthWaitingContent');
175
+ var submitBtn = document.getElementById('oauthPasteSubmit');
176
+ if (fallback) fallback.style.display = 'block';
177
+ if (waitContent) waitContent.style.display = 'none';
178
+ if (submitBtn) submitBtn.style.display = 'block';
179
+ var input = document.getElementById('oauthPasteInput');
180
+ if (input) {
181
+ input.addEventListener('keydown', function(e) { if (e.key === 'Enter') submitOAuthPasteUrl(); });
182
+ setTimeout(function() { input.focus(); }, 100);
183
+ }
184
+ }
185
+
186
+ function removeOAuthModal() {
187
+ var el = document.getElementById('oauthWaitingModal');
173
188
  if (el) el.remove();
174
189
  }
175
190
 
@@ -193,7 +208,7 @@
193
208
  if (data.success) {
194
209
  cleanupOAuthPolling();
195
210
  authRunning = false;
196
- removeOAuthPasteModal();
211
+ removeOAuthModal();
197
212
  refresh();
198
213
  } else {
199
214
  if (errorEl) { errorEl.textContent = data.error || 'Failed to complete authentication.'; errorEl.style.display = 'block'; }
@@ -217,26 +232,28 @@
217
232
  if (data.authUrl) {
218
233
  window.open(data.authUrl, '_blank');
219
234
  if (agentId === 'gemini') {
220
- var needsPaste = data.mode === 'cli-remote';
221
- if (needsPaste) showOAuthPasteModal();
235
+ showOAuthWaitingModal();
222
236
  cleanupOAuthPolling();
223
237
  oauthPollInterval = setInterval(function() {
224
238
  fetch(BASE + '/api/gemini-oauth/status').then(function(r) { return r.json(); }).then(function(status) {
225
239
  if (status.status === 'success') {
226
240
  cleanupOAuthPolling();
227
241
  authRunning = false;
228
- removeOAuthPasteModal();
242
+ removeOAuthModal();
229
243
  refresh();
230
244
  } else if (status.status === 'error') {
231
245
  cleanupOAuthPolling();
232
246
  authRunning = false;
233
- removeOAuthPasteModal();
247
+ removeOAuthModal();
234
248
  }
235
249
  }).catch(function() {});
236
250
  }, 1500);
251
+ oauthFallbackTimer = setTimeout(function() {
252
+ if (authRunning) showOAuthPasteFallback();
253
+ }, 30000);
237
254
  oauthPollTimeout = setTimeout(function() {
238
255
  cleanupOAuthPolling();
239
- if (authRunning) { authRunning = false; removeOAuthPasteModal(); }
256
+ if (authRunning) { authRunning = false; removeOAuthModal(); }
240
257
  }, 5 * 60 * 1000);
241
258
  }
242
259
  }
@@ -257,7 +274,7 @@
257
274
  if (term) term.write(data.data);
258
275
  } else if (data.type === 'script_stopped') {
259
276
  authRunning = false;
260
- removeOAuthPasteModal();
277
+ removeOAuthModal();
261
278
  cleanupOAuthPolling();
262
279
  var term = getTerminal();
263
280
  var msg = data.error ? data.error : ('exited with code ' + (data.code || 0));