lazy-gravity 0.6.0 → 0.6.2

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.
@@ -55,6 +55,68 @@ const SELECTORS = {
55
55
  /** Keyword to identify message injection target context */
56
56
  CONTEXT_URL_KEYWORD: 'cascade-panel',
57
57
  };
58
+ const WORKSPACE_STATE_SCRIPT = `(() => {
59
+ const panel = document.querySelector('.antigravity-agent-side-panel');
60
+ const scopes = [panel, document].filter(Boolean);
61
+
62
+ let isGenerating = false;
63
+ for (const scope of scopes) {
64
+ const stopEl = scope.querySelector('[data-tooltip-id="input-send-button-cancel-tooltip"]');
65
+ if (stopEl) {
66
+ isGenerating = true;
67
+ break;
68
+ }
69
+ }
70
+
71
+ if (!isGenerating) {
72
+ const normalize = (value) => (value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
73
+ const stopPatterns = [
74
+ /^stop$/,
75
+ /^stop generating$/,
76
+ /^stop response$/,
77
+ /^停止$/,
78
+ /^生成を停止$/,
79
+ /^応答を停止$/,
80
+ ];
81
+ for (const scope of scopes) {
82
+ const buttons = scope.querySelectorAll('button, [role="button"]');
83
+ for (let i = 0; i < buttons.length; i++) {
84
+ const btn = buttons[i];
85
+ const labels = [
86
+ btn.textContent || '',
87
+ btn.getAttribute('aria-label') || '',
88
+ btn.getAttribute('title') || '',
89
+ ];
90
+ if (labels.some((label) => stopPatterns.some((re) => re.test(normalize(label))))) {
91
+ isGenerating = true;
92
+ break;
93
+ }
94
+ }
95
+ if (isGenerating) break;
96
+ }
97
+ }
98
+
99
+ if (!panel) {
100
+ return { isGenerating, sessionTitle: '', hasActiveChat: false };
101
+ }
102
+
103
+ const header = panel.querySelector('div[class*="border-b"]');
104
+ const titleEl = header?.querySelector('div[class*="text-ellipsis"]');
105
+ const rawTitle = titleEl ? (titleEl.textContent || '').trim() : '';
106
+ const hasActiveChat = rawTitle.length > 0 && rawTitle !== 'Agent';
107
+
108
+ return {
109
+ isGenerating,
110
+ sessionTitle: rawTitle || '(Untitled)',
111
+ hasActiveChat,
112
+ };
113
+ })()`;
114
+ const CHAT_READY_TIMEOUT_MS = 6000;
115
+ const CHAT_READY_POLL_MS = 250;
116
+ const INJECT_RETRY_READY_TIMEOUT_MS = 2500;
117
+ const INJECT_RETRY_BACKOFF_MS = 500;
118
+ const MODE_READY_TIMEOUT_MS = 4000;
119
+ const MODE_RETRY_READY_TIMEOUT_MS = 2000;
58
120
  class CdpService extends events_1.EventEmitter {
59
121
  ports;
60
122
  isConnectedFlag = false;
@@ -64,6 +126,7 @@ class CdpService extends events_1.EventEmitter {
64
126
  idCounter = 1;
65
127
  cdpCallTimeout = 30000;
66
128
  targetUrl = null;
129
+ targetId = null;
67
130
  /** Number of auto-reconnect attempts on disconnect */
68
131
  maxReconnectAttempts;
69
132
  /** Delay between reconnect attempts (ms) */
@@ -78,14 +141,34 @@ class CdpService extends events_1.EventEmitter {
78
141
  currentWorkspacePath = null;
79
142
  /** Workspace switching flag (suppresses disconnected event) */
80
143
  isSwitchingWorkspace = false;
144
+ accountName;
145
+ accountPorts;
146
+ accountUserDataDirs;
81
147
  constructor(options = {}) {
82
148
  super();
83
- this.ports = options.portsToScan || [...cdpPorts_1.CDP_PORTS];
149
+ this.accountName = options.accountName || 'default';
150
+ this.accountPorts = options.accountPorts || {};
151
+ this.accountUserDataDirs = options.accountUserDataDirs || {};
152
+ this.ports = options.portsToScan || this.resolveAccountPorts(this.accountName);
84
153
  if (options.cdpCallTimeout)
85
154
  this.cdpCallTimeout = options.cdpCallTimeout;
86
155
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 3;
87
156
  this.reconnectDelayMs = options.reconnectDelayMs ?? 2000;
88
157
  }
158
+ resolveAccountPorts(accountName) {
159
+ const explicitPort = this.accountPorts[accountName];
160
+ if (Number.isInteger(explicitPort) && explicitPort > 0) {
161
+ return [explicitPort];
162
+ }
163
+ return [...cdpPorts_1.CDP_PORTS];
164
+ }
165
+ resolveConfiguredUserDataDir(accountName) {
166
+ const configured = this.accountUserDataDirs[accountName];
167
+ if (typeof configured === 'string' && configured.trim().length > 0) {
168
+ return configured.trim();
169
+ }
170
+ return null;
171
+ }
89
172
  async getJson(url) {
90
173
  return new Promise((resolve, reject) => {
91
174
  http.get(url, (res) => {
@@ -141,6 +224,7 @@ class CdpService extends events_1.EventEmitter {
141
224
  }
142
225
  if (target && target.webSocketDebuggerUrl) {
143
226
  this.targetUrl = target.webSocketDebuggerUrl;
227
+ this.targetId = typeof target.id === 'string' ? target.id : null;
144
228
  // Extract workspace name from title (e.g., "ProjectName — Antigravity")
145
229
  if (target.title && !this.currentWorkspaceName) {
146
230
  const titleParts = target.title.split(/\\s[—–-]\\s/);
@@ -266,10 +350,48 @@ class CdpService extends events_1.EventEmitter {
266
350
  }
267
351
  this.isConnectedFlag = false;
268
352
  this.contexts = [];
353
+ this.targetId = null;
269
354
  this.currentWorkspacePath = null;
270
355
  this.currentWorkspaceName = null;
271
356
  this.clearPendingCalls(new Error('disconnect() was called'));
272
357
  }
358
+ async evaluateAcrossContexts(expression, accept, options) {
359
+ const awaitPromise = options?.awaitPromise ?? true;
360
+ const contexts = this.getContexts();
361
+ let firstValue = null;
362
+ if (contexts.length === 0) {
363
+ const result = await this.call('Runtime.evaluate', {
364
+ expression,
365
+ returnByValue: true,
366
+ awaitPromise,
367
+ });
368
+ return {
369
+ value: (result?.result?.value ?? null),
370
+ contextId: null,
371
+ };
372
+ }
373
+ for (const ctx of contexts) {
374
+ try {
375
+ const result = await this.call('Runtime.evaluate', {
376
+ expression,
377
+ returnByValue: true,
378
+ awaitPromise,
379
+ contextId: ctx.id,
380
+ });
381
+ const value = (result?.result?.value ?? null);
382
+ if (!firstValue) {
383
+ firstValue = { value, contextId: ctx.id };
384
+ }
385
+ if (accept(value)) {
386
+ return { value, contextId: ctx.id };
387
+ }
388
+ }
389
+ catch {
390
+ // Try the next context.
391
+ }
392
+ }
393
+ return firstValue ?? { value: null, contextId: null };
394
+ }
273
395
  /**
274
396
  * Return the currently connected workspace name.
275
397
  */
@@ -302,6 +424,19 @@ class CdpService extends events_1.EventEmitter {
302
424
  this.isSwitchingWorkspace = false;
303
425
  }
304
426
  }
427
+ async openWorkspace(workspacePath) {
428
+ const projectName = (0, pathUtils_1.extractProjectNameFromPath)(workspacePath);
429
+ this.currentWorkspacePath = workspacePath;
430
+ try {
431
+ return await this.discoverAndConnectForWorkspace(workspacePath);
432
+ }
433
+ catch (error) {
434
+ logger_1.logger.info(`[CdpService] Explicit open requested for workspace "${projectName}" ` +
435
+ `on account "${this.accountName}" — retrying with launch ` +
436
+ `(reason: ${error?.message || String(error)})`);
437
+ return this.launchAndConnectWorkspace(workspacePath, projectName, true);
438
+ }
439
+ }
305
440
  /**
306
441
  * Verify whether the currently attached page still represents the expected workspace.
307
442
  */
@@ -386,6 +521,7 @@ class CdpService extends events_1.EventEmitter {
386
521
  }
387
522
  this.disconnectQuietly();
388
523
  this.targetUrl = page.webSocketDebuggerUrl;
524
+ this.targetId = typeof page.id === 'string' ? page.id : null;
389
525
  await this.connect();
390
526
  this.currentWorkspaceName = projectName;
391
527
  logger_1.logger.debug(`[CdpService] Connected to workspace "${projectName}"`);
@@ -407,6 +543,7 @@ class CdpService extends events_1.EventEmitter {
407
543
  // Temporarily connect to retrieve document.title
408
544
  this.disconnectQuietly();
409
545
  this.targetUrl = page.webSocketDebuggerUrl;
546
+ this.targetId = typeof page.id === 'string' ? page.id : null;
410
547
  await this.connect();
411
548
  const result = await this.call('Runtime.evaluate', {
412
549
  expression: 'document.title',
@@ -511,20 +648,53 @@ class CdpService extends events_1.EventEmitter {
511
648
  /**
512
649
  * Launch Antigravity and wait for a new workbench page to appear, then connect.
513
650
  */
514
- async launchAndConnectWorkspace(workspacePath, projectName) {
651
+ async launchAndConnectWorkspace(workspacePath, projectName, explicitOpen = false) {
652
+ const targetPort = this.ports[0] ?? null;
653
+ const configuredUserDataDir = this.resolveConfiguredUserDataDir(this.accountName);
654
+ const resolvedUserDataDir = explicitOpen && targetPort !== null
655
+ ? (configuredUserDataDir ?? await this.resolveRunningUserDataDirForPort(targetPort))
656
+ : null;
657
+ if (explicitOpen && this.accountName !== 'default' && targetPort !== null && !resolvedUserDataDir) {
658
+ throw new Error(`Could not determine user-data-dir for running account "${this.accountName}" ` +
659
+ `(CDP port ${targetPort}). Make sure that Antigravity instance is already running with that port.`);
660
+ }
661
+ if (!explicitOpen && this.accountName !== 'default' && targetPort !== null) {
662
+ logger_1.logger.warn(`[CdpService] Workspace "${projectName}" not found on account "${this.accountName}" ` +
663
+ `(port=${targetPort}). Skipping auto-launch to avoid opening the wrong Antigravity instance.`);
664
+ throw new Error(`Workspace "${projectName}" is not open in account "${this.accountName}" ` +
665
+ `(CDP port ${targetPort}). Open it with Antigravity Cockpit or use "/project reopen" ` +
666
+ `command (Telegram: /project_reopen).`);
667
+ }
515
668
  // Open as folder using Antigravity CLI (not as workspace mode).
516
669
  // `open -a Antigravity` may open as workspace, resulting in title "Untitled (Workspace)".
517
670
  // CLI --new-window opens as folder, immediately reflecting directory name in title.
518
671
  const antigravityCli = (0, pathUtils_1.getAntigravityCliPath)();
519
- logger_1.logger.debug(`[CdpService] Launching Antigravity: ${antigravityCli} --new-window ${workspacePath}`);
672
+ const launchArgs = [
673
+ ...(resolvedUserDataDir ? ['--user-data-dir', resolvedUserDataDir] : []),
674
+ ...(targetPort !== null ? [`--remote-debugging-port=${targetPort}`] : []),
675
+ '--new-window',
676
+ workspacePath,
677
+ ];
678
+ logger_1.logger.debug(`[CdpService] Launching Antigravity for account "${this.accountName}" ` +
679
+ `(port=${targetPort ?? 'unknown'}, userDataDir=${resolvedUserDataDir ?? 'default'}) ` +
680
+ `${antigravityCli} ${launchArgs.join(' ')}`);
520
681
  try {
521
- await this.runCommand(antigravityCli, ['--new-window', workspacePath]);
682
+ await this.runCommand(antigravityCli, launchArgs);
522
683
  }
523
684
  catch (error) {
524
685
  // Fall back to open -a if CLI not found (macOS only)
525
686
  logger_1.logger.warn(`[CdpService] CLI launch failed, falling back to open -a (if macOS): ${error?.message || String(error)}`);
526
687
  if (process.platform === 'darwin') {
527
- await this.runCommand('open', ['-a', 'Antigravity', workspacePath]);
688
+ const openArgs = [
689
+ '-n',
690
+ '-a',
691
+ 'Antigravity',
692
+ '--args',
693
+ ...(resolvedUserDataDir ? ['--user-data-dir', resolvedUserDataDir] : []),
694
+ ...(targetPort !== null ? [`--remote-debugging-port=${targetPort}`] : []),
695
+ workspacePath,
696
+ ];
697
+ await this.runCommand('open', openArgs);
528
698
  }
529
699
  else {
530
700
  throw error;
@@ -693,6 +863,62 @@ class CdpService extends events_1.EventEmitter {
693
863
  });
694
864
  });
695
865
  }
866
+ async resolveRunningUserDataDirForPort(port) {
867
+ const commandLines = await this.getProcessCommandLines();
868
+ if (commandLines.length === 0)
869
+ return null;
870
+ const portPattern = new RegExp(`--remote-debugging-port(?:=|\\s+)${port}(?:\\s|$)`);
871
+ for (const line of commandLines) {
872
+ const lowerLine = line.toLowerCase();
873
+ if (!lowerLine.includes('antigravity') || !portPattern.test(line) || !line.includes('--user-data-dir')) {
874
+ continue;
875
+ }
876
+ const match = line.match(/--user-data-dir(?:=|\s+)("([^"]+)"|'([^']+)'|([^\s]+))/);
877
+ const userDataDir = match?.[2] || match?.[3] || match?.[4];
878
+ if (userDataDir) {
879
+ logger_1.logger.info(`[CdpService] Resolved user-data-dir for CDP port ${port}: ${userDataDir}`);
880
+ return userDataDir;
881
+ }
882
+ }
883
+ return null;
884
+ }
885
+ async getProcessCommandLines() {
886
+ const run = (command, args) => new Promise((resolve, reject) => {
887
+ (0, child_process_1.execFile)(command, args, { windowsHide: true, maxBuffer: 20 * 1024 * 1024 }, (error, stdout) => {
888
+ if (error) {
889
+ reject(error);
890
+ return;
891
+ }
892
+ resolve(stdout);
893
+ });
894
+ });
895
+ const parseLines = (output) => output
896
+ .split('\n')
897
+ .map((line) => line.trim())
898
+ .filter((line) => line.length > 0);
899
+ try {
900
+ if (process.platform === 'win32') {
901
+ try {
902
+ const psOutput = await run('powershell.exe', [
903
+ '-NoProfile',
904
+ '-Command',
905
+ 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine | Where-Object { $_ }',
906
+ ]);
907
+ return parseLines(psOutput);
908
+ }
909
+ catch {
910
+ const wmicOutput = await run('wmic', ['process', 'get', 'CommandLine']);
911
+ return parseLines(wmicOutput).filter((line) => line !== 'CommandLine');
912
+ }
913
+ }
914
+ const psOutput = await run('ps', ['-axo', 'command=']);
915
+ return parseLines(psOutput);
916
+ }
917
+ catch (error) {
918
+ logger_1.logger.warn(`[CdpService] Failed to inspect Antigravity processes: ${error instanceof Error ? error.message : String(error)}`);
919
+ return [];
920
+ }
921
+ }
696
922
  /**
697
923
  * Quietly disconnect the existing connection (no reconnect attempts).
698
924
  * Used during workspace switching.
@@ -711,6 +937,81 @@ class CdpService extends events_1.EventEmitter {
711
937
  this.contexts = [];
712
938
  this.clearPendingCalls(new Error('Disconnected for workspace switch'));
713
939
  this.targetUrl = null;
940
+ this.targetId = null;
941
+ }
942
+ }
943
+ async closeCurrentTarget() {
944
+ if (!this.targetId) {
945
+ return false;
946
+ }
947
+ try {
948
+ await this.call('Target.closeTarget', { targetId: this.targetId });
949
+ return true;
950
+ }
951
+ finally {
952
+ await this.disconnect();
953
+ }
954
+ }
955
+ async inspectWorkspaceRuntimeState() {
956
+ const result = await this.evaluateAcrossContexts(WORKSPACE_STATE_SCRIPT, (value) => !!(value && typeof value === 'object' && value.hasActiveChat));
957
+ const value = result.value;
958
+ return {
959
+ isGenerating: !!(value && typeof value === 'object' && value.isGenerating),
960
+ sessionTitle: value && typeof value === 'object' && typeof value.sessionTitle === 'string'
961
+ ? value.sessionTitle
962
+ : '(Untitled)',
963
+ hasActiveChat: !!(value && typeof value === 'object' && value.hasActiveChat),
964
+ contextId: result.contextId,
965
+ };
966
+ }
967
+ async closeCurrentTargetGracefully(timeoutMs = 5000) {
968
+ if (!this.targetId) {
969
+ return false;
970
+ }
971
+ const closingTargetId = this.targetId;
972
+ try {
973
+ await this.call('Page.bringToFront', {}).catch(() => { });
974
+ const modifiers = process.platform === 'darwin' ? 4 : 2;
975
+ await this.call('Input.dispatchKeyEvent', {
976
+ type: 'keyDown',
977
+ key: 'w',
978
+ code: 'KeyW',
979
+ modifiers,
980
+ windowsVirtualKeyCode: 87,
981
+ nativeVirtualKeyCode: 87,
982
+ });
983
+ await this.call('Input.dispatchKeyEvent', {
984
+ type: 'keyUp',
985
+ key: 'w',
986
+ code: 'KeyW',
987
+ modifiers,
988
+ windowsVirtualKeyCode: 87,
989
+ nativeVirtualKeyCode: 87,
990
+ });
991
+ const deadline = Date.now() + timeoutMs;
992
+ while (Date.now() <= deadline) {
993
+ let stillOpen = false;
994
+ for (const port of this.ports) {
995
+ try {
996
+ const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
997
+ if (list.some((page) => page?.id === closingTargetId)) {
998
+ stillOpen = true;
999
+ break;
1000
+ }
1001
+ }
1002
+ catch {
1003
+ // Ignore port failures while polling close state.
1004
+ }
1005
+ }
1006
+ if (!stillOpen) {
1007
+ return true;
1008
+ }
1009
+ await new Promise((resolve) => setTimeout(resolve, 250));
1010
+ }
1011
+ return false;
1012
+ }
1013
+ finally {
1014
+ await this.disconnect();
714
1015
  }
715
1016
  }
716
1017
  /**
@@ -896,6 +1197,112 @@ class CdpService extends events_1.EventEmitter {
896
1197
  }
897
1198
  return { ok: false, error: 'Chat input field not found' };
898
1199
  }
1200
+ async waitForChatInputReady(timeoutMs = CHAT_READY_TIMEOUT_MS) {
1201
+ const deadline = Date.now() + timeoutMs;
1202
+ let lastError = 'Chat input field not found';
1203
+ while (Date.now() < deadline) {
1204
+ const focusResult = await this.focusChatInput();
1205
+ if (focusResult.ok) {
1206
+ return focusResult;
1207
+ }
1208
+ lastError = focusResult.error || lastError;
1209
+ await new Promise((r) => setTimeout(r, CHAT_READY_POLL_MS));
1210
+ }
1211
+ return { ok: false, error: lastError };
1212
+ }
1213
+ isTransientInjectError(error) {
1214
+ const message = String(error || '');
1215
+ return [
1216
+ 'No editor found',
1217
+ 'Chat input field not found',
1218
+ 'WebSocket is not connected',
1219
+ 'WebSocket disconnected',
1220
+ ].some((fragment) => message.includes(fragment));
1221
+ }
1222
+ async isModeToggleReady() {
1223
+ const expression = '(() => {'
1224
+ + ' const uiNameMap = { fast: "Fast", plan: "Planning" };'
1225
+ + ' const knownModes = Object.values(uiNameMap).map(n => n.toLowerCase());'
1226
+ + ' const allBtns = Array.from(document.querySelectorAll("button"));'
1227
+ + ' const visibleBtns = allBtns.filter(b => b.offsetParent !== null);'
1228
+ + ' const modeToggleBtn = visibleBtns.find(b => {'
1229
+ + ' const text = (b.textContent || "").trim().toLowerCase();'
1230
+ + ' const hasChevron = b.querySelector("svg[class*=\\"chevron\\"]");'
1231
+ + ' return knownModes.some(m => text === m) && hasChevron;'
1232
+ + ' });'
1233
+ + ' return !!modeToggleBtn;'
1234
+ + '})()';
1235
+ try {
1236
+ const contextId = this.getPrimaryContextId();
1237
+ const callParams = {
1238
+ expression,
1239
+ returnByValue: true,
1240
+ awaitPromise: false,
1241
+ };
1242
+ if (contextId !== null)
1243
+ callParams.contextId = contextId;
1244
+ const res = await this.call('Runtime.evaluate', callParams);
1245
+ return Boolean(res?.result?.value);
1246
+ }
1247
+ catch {
1248
+ return false;
1249
+ }
1250
+ }
1251
+ async waitForModeToggleReady(timeoutMs = MODE_READY_TIMEOUT_MS) {
1252
+ const deadline = Date.now() + timeoutMs;
1253
+ while (Date.now() < deadline) {
1254
+ if (await this.isModeToggleReady()) {
1255
+ return true;
1256
+ }
1257
+ await new Promise((r) => setTimeout(r, CHAT_READY_POLL_MS));
1258
+ }
1259
+ return false;
1260
+ }
1261
+ isTransientModeError(error) {
1262
+ const message = String(error || '');
1263
+ return [
1264
+ 'Mode toggle button not found',
1265
+ 'WebSocket is not connected',
1266
+ 'WebSocket disconnected',
1267
+ ].some((fragment) => message.includes(fragment));
1268
+ }
1269
+ async injectMessageCore(text, imageFilePaths) {
1270
+ const focusResult = await this.waitForChatInputReady();
1271
+ if (!focusResult.ok) {
1272
+ return { ok: false, error: focusResult.error || 'Chat input field not found' };
1273
+ }
1274
+ // Clear any existing text in the input field before injecting.
1275
+ await this.clearInputField();
1276
+ if (imageFilePaths && imageFilePaths.length > 0) {
1277
+ const attachResult = await this.attachImageFiles(imageFilePaths, focusResult.contextId);
1278
+ if (!attachResult.ok) {
1279
+ return { ok: false, error: attachResult.error || 'Failed to attach images' };
1280
+ }
1281
+ }
1282
+ await this.call('Input.insertText', { text });
1283
+ await new Promise(r => setTimeout(r, 200));
1284
+ await this.pressEnterToSend();
1285
+ return {
1286
+ ok: true,
1287
+ method: 'enter',
1288
+ contextId: focusResult.contextId,
1289
+ };
1290
+ }
1291
+ async retryInjectOnce(text, firstError, imageFilePaths) {
1292
+ logger_1.logger.warn(`[CdpService] Initial message injection failed: ${firstError}. Retrying once after readiness check...`);
1293
+ try {
1294
+ await this.reconnectOnDemand(INJECT_RETRY_READY_TIMEOUT_MS);
1295
+ }
1296
+ catch {
1297
+ // Ignore reconnect failures here; readiness check below will produce the final error.
1298
+ }
1299
+ await new Promise((r) => setTimeout(r, INJECT_RETRY_BACKOFF_MS));
1300
+ const ready = await this.waitForChatInputReady(INJECT_RETRY_READY_TIMEOUT_MS);
1301
+ if (!ready.ok) {
1302
+ return { ok: false, error: ready.error || firstError };
1303
+ }
1304
+ return this.injectMessageCore(text, imageFilePaths);
1305
+ }
899
1306
  /**
900
1307
  * Select all text in the focused input and delete it to ensure a clean state.
901
1308
  * Uses Meta+A (select all) then Backspace (delete) via CDP key events.
@@ -1080,18 +1487,11 @@ class CdpService extends events_1.EventEmitter {
1080
1487
  if (!this.isConnectedFlag || !this.ws) {
1081
1488
  throw new Error('Not connected to CDP. Call connect() first.');
1082
1489
  }
1083
- const focusResult = await this.focusChatInput();
1084
- if (!focusResult.ok) {
1085
- return { ok: false, error: focusResult.error || 'Chat input field not found' };
1490
+ const result = await this.injectMessageCore(text);
1491
+ if (result.ok || !this.isTransientInjectError(result.error)) {
1492
+ return result;
1086
1493
  }
1087
- // Clear any existing text in the input field before injecting
1088
- await this.clearInputField();
1089
- // 1. Input text via CDP Input.insertText
1090
- await this.call('Input.insertText', { text });
1091
- await new Promise(r => setTimeout(r, 200));
1092
- // 2. Send via Enter key
1093
- await this.pressEnterToSend();
1094
- return { ok: true, method: 'enter', contextId: focusResult.contextId };
1494
+ return this.retryInjectOnce(text, result.error || 'Message injection failed');
1095
1495
  }
1096
1496
  /**
1097
1497
  * Attach image files to the UI and send the specified text.
@@ -1100,20 +1500,11 @@ class CdpService extends events_1.EventEmitter {
1100
1500
  if (!this.isConnectedFlag || !this.ws) {
1101
1501
  throw new Error('Not connected to CDP. Call connect() first.');
1102
1502
  }
1103
- const focusResult = await this.focusChatInput();
1104
- if (!focusResult.ok) {
1105
- return { ok: false, error: focusResult.error || 'Chat input field not found' };
1106
- }
1107
- // Clear any existing text in the input field before injecting
1108
- await this.clearInputField();
1109
- const attachResult = await this.attachImageFiles(imageFilePaths, focusResult.contextId);
1110
- if (!attachResult.ok) {
1111
- return { ok: false, error: attachResult.error || 'Failed to attach images' };
1503
+ const result = await this.injectMessageCore(text, imageFilePaths);
1504
+ if (result.ok || !this.isTransientInjectError(result.error)) {
1505
+ return result;
1112
1506
  }
1113
- await this.call('Input.insertText', { text });
1114
- await new Promise(r => setTimeout(r, 200));
1115
- await this.pressEnterToSend();
1116
- return { ok: true, method: 'enter', contextId: focusResult.contextId };
1507
+ return this.retryInjectOnce(text, result.error || 'Image message injection failed', imageFilePaths);
1117
1508
  }
1118
1509
  /**
1119
1510
  * Extract images from the latest AI response.
@@ -1351,6 +1742,7 @@ class CdpService extends events_1.EventEmitter {
1351
1742
  if (!this.isConnectedFlag || !this.ws) {
1352
1743
  await this.reconnectOnDemand();
1353
1744
  }
1745
+ await this.waitForModeToggleReady();
1354
1746
  const safeMode = JSON.stringify(modeName);
1355
1747
  // Internal mode name -> Antigravity UI display name mapping
1356
1748
  const uiNameMap = JSON.stringify({ fast: 'Fast', plan: 'Planning' });
@@ -1443,7 +1835,24 @@ class CdpService extends events_1.EventEmitter {
1443
1835
  if (value?.ok) {
1444
1836
  return { ok: true, mode: value.mode };
1445
1837
  }
1446
- return { ok: false, error: value?.error || 'UI operation failed (setUiMode)' };
1838
+ const errorMessage = value?.error || 'UI operation failed (setUiMode)';
1839
+ if (!this.isTransientModeError(errorMessage)) {
1840
+ return { ok: false, error: errorMessage };
1841
+ }
1842
+ logger_1.logger.warn(`[CdpService] setUiMode initial attempt failed: ${errorMessage}. Retrying once after readiness check...`);
1843
+ try {
1844
+ await this.reconnectOnDemand(MODE_RETRY_READY_TIMEOUT_MS);
1845
+ }
1846
+ catch {
1847
+ // Fall through to the readiness wait and retry below.
1848
+ }
1849
+ await this.waitForModeToggleReady(MODE_RETRY_READY_TIMEOUT_MS);
1850
+ const retryRes = await this.call('Runtime.evaluate', callParams);
1851
+ const retryValue = retryRes?.result?.value;
1852
+ if (retryValue?.ok) {
1853
+ return { ok: true, mode: retryValue.mode };
1854
+ }
1855
+ return { ok: false, error: retryValue?.error || errorMessage };
1447
1856
  }
1448
1857
  catch (error) {
1449
1858
  return { ok: false, error: error?.message || String(error) };
@@ -1457,26 +1866,152 @@ class CdpService extends events_1.EventEmitter {
1457
1866
  throw new Error('Not connected to CDP.');
1458
1867
  }
1459
1868
  const expression = `(async () => {
1460
- return Array.from(document.querySelectorAll('div.cursor-pointer'))
1461
- .map(e => ({text: (e.textContent || '').trim().replace(/New$/, ''), class: e.className}))
1462
- .filter(e => e.class.includes('px-2 py-1 flex items-center justify-between') || e.text.includes('Gemini') || e.text.includes('GPT') || e.text.includes('Claude'))
1463
- .map(e => e.text);
1869
+ const normalize = (text) => (text || '').trim().replace(/New$/, '').trim();
1870
+ const isBlockedModelLabel = (text) => /assist|open in terminal|terminal|code change|claude code|\\bchange\\b/i.test(text || '');
1871
+ const looksLikeModel = (text) => {
1872
+ const normalized = normalize(text).toLowerCase();
1873
+ if (!normalized || isBlockedModelLabel(normalized)) return false;
1874
+ return /gemini\\s*\\d|gpt(?:[-\\s]?\\d|[-\\s]?oss)|claude\\s+(?:opus|sonnet|haiku)|(?:opus|sonnet|haiku)\\s*\\d/i.test(normalized);
1875
+ };
1876
+ const isVisible = (el) => {
1877
+ if (!(el instanceof HTMLElement)) return false;
1878
+ const style = window.getComputedStyle(el);
1879
+ const rect = el.getBoundingClientRect();
1880
+ return style.visibility !== 'hidden'
1881
+ && style.display !== 'none'
1882
+ && rect.width > 0
1883
+ && rect.height > 0;
1884
+ };
1885
+ const getLabel = (el) => {
1886
+ if (!(el instanceof HTMLElement)) return '';
1887
+ const parts = [
1888
+ el.textContent || '',
1889
+ el.getAttribute('aria-label') || '',
1890
+ el.getAttribute('title') || '',
1891
+ el.getAttribute('data-value') || '',
1892
+ ];
1893
+ return normalize(parts.find((part) => normalize(part).length > 0) || '');
1894
+ };
1895
+ const scopeSelectors = [
1896
+ '[role="dialog"]',
1897
+ '[role="listbox"]',
1898
+ '[role="menu"]',
1899
+ '[data-radix-popper-content-wrapper]',
1900
+ '[data-slot*="popover"]',
1901
+ '[data-state="open"]',
1902
+ '[class*="popover"]',
1903
+ '[class*="dropdown"]',
1904
+ '[class*="menu"]',
1905
+ ];
1906
+ const itemSelectors = [
1907
+ '[role="option"]',
1908
+ '[role="menuitem"]',
1909
+ '[aria-selected]',
1910
+ '[aria-checked]',
1911
+ 'button',
1912
+ '[role="button"]',
1913
+ 'div.cursor-pointer',
1914
+ 'div[class*="cursor-pointer"]',
1915
+ ];
1916
+ const getScopes = () => {
1917
+ const scopes = [document];
1918
+ for (const el of document.querySelectorAll(scopeSelectors.join(','))) {
1919
+ if (isVisible(el)) scopes.push(el);
1920
+ }
1921
+ return Array.from(new Set(scopes));
1922
+ };
1923
+ const hasOpenPicker = () => getScopes().some((scope) => scope !== document);
1924
+ const closePicker = async () => {
1925
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
1926
+ if (active) active.blur();
1927
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
1928
+ document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true, cancelable: true }));
1929
+ document.body?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
1930
+ document.body?.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
1931
+ document.body?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1932
+ await new Promise((resolve) => setTimeout(resolve, 80));
1933
+ };
1934
+ const collectModels = () => {
1935
+ const seen = new Set();
1936
+ const labels = [];
1937
+ for (const scope of getScopes()) {
1938
+ for (const el of scope.querySelectorAll(itemSelectors.join(','))) {
1939
+ if (!isVisible(el)) continue;
1940
+ const label = getLabel(el);
1941
+ if (!looksLikeModel(label)) continue;
1942
+ const key = label.toLowerCase();
1943
+ if (seen.has(key)) continue;
1944
+ seen.add(key);
1945
+ labels.push(label);
1946
+ }
1947
+ }
1948
+ return labels;
1949
+ };
1950
+ const triggerSelectors = [
1951
+ '[role="combobox"]',
1952
+ '[aria-haspopup="listbox"]',
1953
+ '[aria-haspopup="menu"]',
1954
+ '[aria-expanded]',
1955
+ 'button',
1956
+ '[role="button"]',
1957
+ 'div.cursor-pointer',
1958
+ 'div[class*="cursor-pointer"]',
1959
+ ];
1960
+
1961
+ let models = collectModels();
1962
+ if (models.length > 1) {
1963
+ if (hasOpenPicker()) await closePicker();
1964
+ return models;
1965
+ }
1966
+
1967
+ const triggers = Array.from(document.querySelectorAll(triggerSelectors.join(',')))
1968
+ .filter(isVisible)
1969
+ .filter((el) => {
1970
+ const label = getLabel(el);
1971
+ const attrs = normalize([
1972
+ el.getAttribute('aria-label') || '',
1973
+ el.getAttribute('title') || '',
1974
+ el.getAttribute('aria-haspopup') || '',
1975
+ el.className || '',
1976
+ ].join(' '));
1977
+ return looksLikeModel(label) || /model|listbox|combobox|popover|dropdown/.test(attrs);
1978
+ });
1979
+
1980
+ for (const trigger of triggers) {
1981
+ trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1982
+ await new Promise((resolve) => setTimeout(resolve, 300));
1983
+ models = collectModels();
1984
+ if (models.length > 1) {
1985
+ if (hasOpenPicker()) await closePicker();
1986
+ return models;
1987
+ }
1988
+ }
1989
+
1990
+ if (hasOpenPicker()) await closePicker();
1991
+ return models;
1464
1992
  })()`;
1465
1993
  try {
1466
- const contextId = this.getPrimaryContextId();
1467
- const callParams = {
1468
- expression,
1469
- returnByValue: true,
1470
- awaitPromise: true,
1471
- };
1472
- if (contextId !== null)
1473
- callParams.contextId = contextId;
1474
- const res = await this.call('Runtime.evaluate', callParams);
1475
- const value = res?.result?.value;
1476
- if (Array.isArray(value) && value.length > 0) {
1477
- // remove duplicates
1478
- return Array.from(new Set(value));
1994
+ const contexts = this.getContexts();
1995
+ const contextIds = [
1996
+ this.getPrimaryContextId(),
1997
+ ...contexts.map((ctx) => ctx.id),
1998
+ ].filter((value, index, arr) => typeof value === 'number' && arr.indexOf(value) === index);
1999
+ const targets = contextIds.length > 0 ? contextIds : [null];
2000
+ for (const contextId of targets) {
2001
+ const params = {
2002
+ expression,
2003
+ returnByValue: true,
2004
+ awaitPromise: true,
2005
+ };
2006
+ if (typeof contextId === 'number')
2007
+ params.contextId = contextId;
2008
+ const res = await this.call('Runtime.evaluate', params);
2009
+ const value = res?.result?.value;
2010
+ if (Array.isArray(value) && value.length > 0) {
2011
+ return Array.from(new Set(value));
2012
+ }
1479
2013
  }
2014
+ logger_1.logger.warn('[CdpService] getUiModels returned no models from any execution context.');
1480
2015
  return [];
1481
2016
  }
1482
2017
  catch (error) {
@@ -1492,17 +2027,81 @@ class CdpService extends events_1.EventEmitter {
1492
2027
  return null;
1493
2028
  }
1494
2029
  const expression = `(() => {
1495
- return Array.from(document.querySelectorAll('div.cursor-pointer'))
1496
- .find(e => e.className.includes('px-2 py-1 flex items-center justify-between') && e.className.includes('bg-gray-500/20'))
1497
- ?.textContent?.trim().replace(/New$/, '') || null;
2030
+ const normalize = (text) => (text || '').trim().replace(/New$/, '').trim();
2031
+ const isBlockedModelLabel = (text) => /assist|open in terminal|terminal|code change|claude code|\\bchange\\b/i.test(text || '');
2032
+ const looksLikeModel = (text) => {
2033
+ const normalized = normalize(text).toLowerCase();
2034
+ if (!normalized || isBlockedModelLabel(normalized)) return false;
2035
+ return /gemini\\s*\\d|gpt(?:[-\\s]?\\d|[-\\s]?oss)|claude\\s+(?:opus|sonnet|haiku)|(?:opus|sonnet|haiku)\\s*\\d/i.test(normalized);
2036
+ };
2037
+ const isVisible = (el) => {
2038
+ if (!(el instanceof HTMLElement)) return false;
2039
+ const style = window.getComputedStyle(el);
2040
+ const rect = el.getBoundingClientRect();
2041
+ return style.visibility !== 'hidden'
2042
+ && style.display !== 'none'
2043
+ && rect.width > 0
2044
+ && rect.height > 0;
2045
+ };
2046
+ const getLabel = (el) => {
2047
+ if (!(el instanceof HTMLElement)) return '';
2048
+ const parts = [
2049
+ el.textContent || '',
2050
+ el.getAttribute('aria-label') || '',
2051
+ el.getAttribute('title') || '',
2052
+ el.getAttribute('data-value') || '',
2053
+ ];
2054
+ return normalize(parts.find((part) => normalize(part).length > 0) || '');
2055
+ };
2056
+ const getScore = (el) => {
2057
+ if (!(el instanceof HTMLElement)) return 0;
2058
+ let score = 0;
2059
+ if (el.getAttribute('aria-selected') === 'true') score += 5;
2060
+ if (el.getAttribute('aria-checked') === 'true') score += 5;
2061
+ if (el.getAttribute('aria-current') === 'true') score += 4;
2062
+ const dataState = normalize(el.getAttribute('data-state') || '');
2063
+ if (/(checked|active|selected|on|open)/.test(dataState)) score += 4;
2064
+ const classes = normalize(el.className || '');
2065
+ if (classes.includes('bg-gray-500/20')) score += 3;
2066
+ if (classes.includes('selected')) score += 3;
2067
+ if (classes.includes('active')) score += 2;
2068
+ if (el.getAttribute('aria-expanded') === 'true') score += 1;
2069
+ return score;
2070
+ };
2071
+ const candidates = Array.from(document.querySelectorAll(
2072
+ '[role="option"], [role="menuitem"], [role="combobox"], [aria-selected], [aria-checked], ' +
2073
+ '[aria-current], button, [role="button"], div.cursor-pointer, div[class*="cursor-pointer"]'
2074
+ ))
2075
+ .filter(isVisible)
2076
+ .map((el) => ({ el, label: getLabel(el), score: getScore(el) }))
2077
+ .filter((entry) => looksLikeModel(entry.label))
2078
+ .sort((a, b) => b.score - a.score || a.label.length - b.label.length);
2079
+ if (candidates.length === 0) return null;
2080
+ if (candidates[0].score > 0) return candidates[0].label;
2081
+ return candidates[0].label;
1498
2082
  })()`;
1499
2083
  try {
1500
- const contextId = this.getPrimaryContextId();
1501
- const res = await this.call('Runtime.evaluate', {
1502
- expression, returnByValue: true, awaitPromise: true,
1503
- contextId: contextId || undefined
1504
- });
1505
- return res?.result?.value || null;
2084
+ const contexts = this.getContexts();
2085
+ const contextIds = [
2086
+ this.getPrimaryContextId(),
2087
+ ...contexts.map((ctx) => ctx.id),
2088
+ ].filter((value, index, arr) => typeof value === 'number' && arr.indexOf(value) === index);
2089
+ const targets = contextIds.length > 0 ? contextIds : [null];
2090
+ for (const contextId of targets) {
2091
+ const params = {
2092
+ expression,
2093
+ returnByValue: true,
2094
+ awaitPromise: true,
2095
+ };
2096
+ if (typeof contextId === 'number')
2097
+ params.contextId = contextId;
2098
+ const res = await this.call('Runtime.evaluate', params);
2099
+ const value = res?.result?.value;
2100
+ if (typeof value === 'string' && value.trim().length > 0) {
2101
+ return value;
2102
+ }
2103
+ }
2104
+ return null;
1506
2105
  }
1507
2106
  catch (e) {
1508
2107
  return null;
@@ -1515,6 +2114,7 @@ class CdpService extends events_1.EventEmitter {
1515
2114
  * @param modelName Model name to set (e.g., 'gpt-4o', 'claude-3-opus')
1516
2115
  */
1517
2116
  async setUiModel(modelName) {
2117
+ logger_1.logger.info(`[CdpService] setUiModel requested account=${this.accountName} target="${modelName}" connected=${this.isConnectedFlag}`);
1518
2118
  if (!this.isConnectedFlag || !this.ws) {
1519
2119
  await this.reconnectOnDemand();
1520
2120
  }
@@ -1525,68 +2125,325 @@ class CdpService extends events_1.EventEmitter {
1525
2125
  const safeModel = JSON.stringify(modelName);
1526
2126
  const expression = `(async () => {
1527
2127
  const targetModel = ${safeModel};
1528
-
1529
- // Get all items in the model list
1530
- const modelItems = Array.from(document.querySelectorAll('div.cursor-pointer'))
1531
- .filter(e => e.className.includes('px-2 py-1 flex items-center justify-between'));
1532
-
1533
- if (modelItems.length === 0) {
1534
- return { ok: false, error: 'Model list not found. The dropdown may not be open.' };
1535
- }
1536
-
1537
- // Match target model by name (compare after removing New suffix)
1538
- const targetItem = modelItems.find(el => {
1539
- const text = (el.textContent || '').trim().replace(/New$/, '').trim();
1540
- return text === targetModel || text.toLowerCase() === targetModel.toLowerCase();
2128
+ const normalize = (text) => (text || '').trim().replace(/New$/, '').trim();
2129
+ const isBlockedModelLabel = (text) => /assist|open in terminal|terminal|code change|claude code|\\bchange\\b/i.test(text || '');
2130
+ const looksLikeModel = (text) => {
2131
+ const normalized = normalize(text).toLowerCase();
2132
+ if (!normalized || isBlockedModelLabel(normalized)) return false;
2133
+ return /gemini\\s*\\d|gpt(?:[-\\s]?\\d|[-\\s]?oss)|claude\\s+(?:opus|sonnet|haiku)|(?:opus|sonnet|haiku)\\s*\\d/i.test(normalized);
2134
+ };
2135
+ const isVisible = (el) => {
2136
+ if (!(el instanceof HTMLElement)) return false;
2137
+ const style = window.getComputedStyle(el);
2138
+ const rect = el.getBoundingClientRect();
2139
+ return style.visibility !== 'hidden'
2140
+ && style.display !== 'none'
2141
+ && rect.width > 0
2142
+ && rect.height > 0;
2143
+ };
2144
+ const getLabel = (el) => {
2145
+ if (!(el instanceof HTMLElement)) return '';
2146
+ const parts = [
2147
+ el.textContent || '',
2148
+ el.getAttribute('aria-label') || '',
2149
+ el.getAttribute('title') || '',
2150
+ el.getAttribute('data-value') || '',
2151
+ ];
2152
+ return normalize(parts.find((part) => normalize(part).length > 0) || '');
2153
+ };
2154
+ const itemSelectors = [
2155
+ '[role="option"]',
2156
+ '[role="menuitem"]',
2157
+ '[aria-selected]',
2158
+ '[aria-checked]',
2159
+ 'button',
2160
+ '[role="button"]',
2161
+ 'div.cursor-pointer',
2162
+ 'div[class*="cursor-pointer"]',
2163
+ ];
2164
+ const triggerSelectors = [
2165
+ '[role="combobox"]',
2166
+ '[aria-haspopup="listbox"]',
2167
+ '[aria-haspopup="menu"]',
2168
+ '[aria-expanded]',
2169
+ 'button',
2170
+ '[role="button"]',
2171
+ 'div.cursor-pointer',
2172
+ 'div[class*="cursor-pointer"]',
2173
+ ];
2174
+ const scopeSelectors = [
2175
+ '[role="dialog"]',
2176
+ '[role="listbox"]',
2177
+ '[role="menu"]',
2178
+ '[data-radix-popper-content-wrapper]',
2179
+ '[data-slot*="popover"]',
2180
+ '[data-state="open"]',
2181
+ '[class*="popover"]',
2182
+ '[class*="dropdown"]',
2183
+ '[class*="menu"]',
2184
+ ];
2185
+ const getScopes = () => {
2186
+ const scopes = [document];
2187
+ for (const el of document.querySelectorAll(scopeSelectors.join(','))) {
2188
+ if (isVisible(el)) scopes.push(el);
2189
+ }
2190
+ return Array.from(new Set(scopes));
2191
+ };
2192
+ const getOpenPickerScopes = () => getScopes().filter((scope) => scope !== document);
2193
+ const hasOpenPicker = () => getOpenPickerScopes().length > 0;
2194
+ const isSelected = (el) => {
2195
+ if (!(el instanceof HTMLElement)) return false;
2196
+ const classes = normalize(el.className || '');
2197
+ const state = normalize(el.getAttribute('data-state') || '');
2198
+ return el.getAttribute('aria-selected') === 'true'
2199
+ || el.getAttribute('aria-checked') === 'true'
2200
+ || el.getAttribute('aria-current') === 'true'
2201
+ || /(checked|active|selected|on)/.test(state)
2202
+ || classes.includes('bg-gray-500/20')
2203
+ || classes.includes('selected')
2204
+ || classes.includes('active');
2205
+ };
2206
+ const isModelTrigger = (el) => {
2207
+ if (!(el instanceof HTMLElement) || !isVisible(el)) return false;
2208
+ const label = getLabel(el);
2209
+ const attrs = normalize([
2210
+ el.getAttribute('aria-label') || '',
2211
+ el.getAttribute('title') || '',
2212
+ el.getAttribute('aria-haspopup') || '',
2213
+ el.getAttribute('role') || '',
2214
+ el.getAttribute('data-state') || '',
2215
+ el.className || '',
2216
+ ].join(' '));
2217
+ return looksLikeModel(label)
2218
+ || /select model|current:|model|listbox|combobox|dropdown|popover|dialog/.test(attrs);
2219
+ };
2220
+ const collectModelItems = ({ includeClosedTrigger = false } = {}) => {
2221
+ const items = [];
2222
+ const seen = new Set();
2223
+ const openScopes = getOpenPickerScopes();
2224
+ const scopes = openScopes.length > 0
2225
+ ? openScopes
2226
+ : includeClosedTrigger
2227
+ ? [document]
2228
+ : [];
2229
+ for (const scope of scopes) {
2230
+ for (const el of scope.querySelectorAll(itemSelectors.join(','))) {
2231
+ if (!isVisible(el)) continue;
2232
+ if (scope === document && isModelTrigger(el)) continue;
2233
+ const label = getLabel(el);
2234
+ if (!looksLikeModel(label)) continue;
2235
+ const key = label.toLowerCase() + '::' + normalize((el.getAttribute('role') || '') + ' ' + (el.className || ''));
2236
+ if (seen.has(key)) continue;
2237
+ seen.add(key);
2238
+ items.push({ el, label });
2239
+ }
2240
+ }
2241
+ return items;
2242
+ };
2243
+ const summarizeElement = (el) => {
2244
+ if (!(el instanceof HTMLElement)) return null;
2245
+ return {
2246
+ label: getLabel(el),
2247
+ role: el.getAttribute('role') || '',
2248
+ ariaLabel: el.getAttribute('aria-label') || '',
2249
+ title: el.getAttribute('title') || '',
2250
+ ariaExpanded: el.getAttribute('aria-expanded') || '',
2251
+ ariaHaspopup: el.getAttribute('aria-haspopup') || '',
2252
+ ariaSelected: el.getAttribute('aria-selected') || '',
2253
+ dataState: el.getAttribute('data-state') || '',
2254
+ className: String(el.className || '').slice(0, 120),
2255
+ };
2256
+ };
2257
+ const diagnostics = () => {
2258
+ const triggers = Array.from(document.querySelectorAll(triggerSelectors.join(',')))
2259
+ .filter(isVisible)
2260
+ .map((el) => summarizeElement(el))
2261
+ .filter((entry) => entry && (looksLikeModel(entry.label) || /select model|current:|model|listbox|combobox|dropdown|popover|dialog/.test(normalize(entry.className + ' ' + entry.ariaLabel + ' ' + entry.title + ' ' + entry.ariaHaspopup))))
2262
+ .slice(0, 8);
2263
+ const items = collectModelItems({ includeClosedTrigger: true })
2264
+ .map(({ el }) => summarizeElement(el))
2265
+ .filter(Boolean)
2266
+ .slice(0, 12);
2267
+ return JSON.stringify({ triggers, items, openPicker: hasOpenPicker() });
2268
+ };
2269
+ const getCurrentModelLabel = () => {
2270
+ const selectedItem = collectModelItems({ includeClosedTrigger: true }).find(({ el }) => isSelected(el));
2271
+ if (selectedItem) return selectedItem.label;
2272
+ const trigger = Array.from(document.querySelectorAll(triggerSelectors.join(',')))
2273
+ .filter(isVisible)
2274
+ .map((el) => ({ el, label: getLabel(el) }))
2275
+ .find((entry) => isModelTrigger(entry.el) && looksLikeModel(entry.label));
2276
+ return trigger ? trigger.label : null;
2277
+ };
2278
+ const triggerScore = (el) => {
2279
+ if (!(el instanceof HTMLElement)) return -1;
2280
+ const label = getLabel(el);
2281
+ const ariaLabel = normalize(el.getAttribute('aria-label') || '');
2282
+ const title = normalize(el.getAttribute('title') || '');
2283
+ const role = normalize(el.getAttribute('role') || '');
2284
+ const ariaHaspopup = normalize(el.getAttribute('aria-haspopup') || '');
2285
+ const ariaExpanded = normalize(el.getAttribute('aria-expanded') || '');
2286
+ const classes = normalize(el.className || '');
2287
+ let score = 0;
2288
+ if (/select model|current:/.test(ariaLabel)) score += 10;
2289
+ if (ariaHaspopup === 'dialog') score += 8;
2290
+ if (ariaHaspopup === 'listbox' || ariaHaspopup === 'menu') score += 7;
2291
+ if (role === 'combobox') score += 6;
2292
+ if (ariaExpanded === 'false' || ariaExpanded === 'true') score += 4;
2293
+ if (looksLikeModel(label)) score += 3;
2294
+ if (/model|popover|dropdown/.test(title + ' ' + classes)) score += 2;
2295
+ return score;
2296
+ };
2297
+ const activateTrigger = async (trigger) => {
2298
+ if (!(trigger instanceof HTMLElement)) return false;
2299
+ const tryClick = () => {
2300
+ trigger.focus?.();
2301
+ trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerType: 'mouse' }));
2302
+ trigger.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
2303
+ trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerType: 'mouse' }));
2304
+ trigger.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
2305
+ trigger.click?.();
2306
+ trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
2307
+ };
2308
+ tryClick();
2309
+ for (const delay of [250, 450, 700]) {
2310
+ await new Promise((r) => setTimeout(r, delay));
2311
+ if (hasOpenPicker()) return true;
2312
+ }
2313
+ if (trigger.parentElement instanceof HTMLElement) {
2314
+ try {
2315
+ const rect = trigger.getBoundingClientRect();
2316
+ const parentRect = trigger.parentElement.getBoundingClientRect();
2317
+ if (parentRect.width > rect.width && parentRect.height >= rect.height) {
2318
+ trigger.parentElement.click?.();
2319
+ trigger.parentElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
2320
+ }
2321
+ } catch {}
2322
+ for (const delay of [250, 450]) {
2323
+ await new Promise((r) => setTimeout(r, delay));
2324
+ if (hasOpenPicker()) return true;
2325
+ }
2326
+ }
2327
+ return hasOpenPicker();
2328
+ };
2329
+ const clickPossibleTrigger = async () => {
2330
+ const triggers = Array.from(document.querySelectorAll(triggerSelectors.join(',')))
2331
+ .filter(isVisible)
2332
+ .filter((el) => isModelTrigger(el))
2333
+ .sort((a, b) => triggerScore(b) - triggerScore(a));
2334
+ for (const trigger of triggers) {
2335
+ const opened = await activateTrigger(trigger);
2336
+ const candidateItems = collectModelItems();
2337
+ if (opened || candidateItems.length > 0) {
2338
+ return true;
2339
+ }
2340
+ }
2341
+ return false;
2342
+ };
2343
+
2344
+ let modelItems = collectModelItems();
2345
+ if (modelItems.length === 0 || (modelItems.length <= 1 && !hasOpenPicker())) {
2346
+ await clickPossibleTrigger();
2347
+ modelItems = collectModelItems();
2348
+ }
2349
+
2350
+ if (modelItems.length === 0 || (modelItems.length <= 1 && !hasOpenPicker())) {
2351
+ return {
2352
+ ok: false,
2353
+ error: 'Model list not found. The dropdown may not be open.',
2354
+ diagnostics: diagnostics(),
2355
+ };
2356
+ }
2357
+
2358
+ const targetLabel = normalize(targetModel).toLowerCase();
2359
+ const targetItem = modelItems.find(({ label }) => {
2360
+ const text = normalize(label).toLowerCase();
2361
+ return text === targetLabel;
1541
2362
  });
1542
-
2363
+
1543
2364
  if (!targetItem) {
1544
- const available = modelItems.map(el => (el.textContent || '').trim().replace(/New$/, '').trim()).join(', ');
1545
- return { ok: false, error: 'Model "' + targetModel + '" not found. Available: ' + available };
2365
+ const available = modelItems.map(({ label }) => normalize(label)).join(', ');
2366
+ return {
2367
+ ok: false,
2368
+ error: 'Model "' + targetModel + '" not found. Available: ' + available,
2369
+ diagnostics: diagnostics(),
2370
+ };
1546
2371
  }
1547
-
1548
- // Check if already selected
1549
- if (targetItem.className.includes('bg-gray-500/20') && !targetItem.className.includes('hover:bg-gray-500/20')) {
2372
+
2373
+ if (isSelected(targetItem.el) || getCurrentModelLabel()?.toLowerCase() === targetLabel) {
1550
2374
  return { ok: true, model: targetModel, alreadySelected: true };
1551
2375
  }
1552
-
1553
- // Click to select model
1554
- targetItem.click();
2376
+
2377
+ targetItem.el.click();
1555
2378
  await new Promise(r => setTimeout(r, 500));
1556
-
1557
- // Verify selection was applied
1558
- const updatedItems = Array.from(document.querySelectorAll('div.cursor-pointer'))
1559
- .filter(e => e.className.includes('px-2 py-1 flex items-center justify-between'));
1560
- const selectedItem = updatedItems.find(el => {
1561
- const text = (el.textContent || '').trim().replace(/New$/, '').trim();
1562
- return text === targetModel || text.toLowerCase() === targetModel.toLowerCase();
1563
- });
1564
-
1565
- if (selectedItem && selectedItem.className.includes('bg-gray-500/20') && !selectedItem.className.includes('hover:bg-gray-500/20')) {
2379
+
2380
+ const currentModel = normalize(getCurrentModelLabel() || '').toLowerCase();
2381
+ if (currentModel === targetLabel) {
1566
2382
  return { ok: true, model: targetModel, verified: true };
1567
2383
  }
1568
-
1569
- // Click succeeded but verification failed
1570
- return { ok: true, model: targetModel, verified: false };
2384
+
2385
+ const updatedTarget = collectModelItems().find(({ label }) => normalize(label).toLowerCase() === targetLabel);
2386
+ if (updatedTarget && isSelected(updatedTarget.el)) {
2387
+ return { ok: true, model: targetModel, verified: true };
2388
+ }
2389
+
2390
+ return {
2391
+ ok: false,
2392
+ error: 'Model click did not update selected state.',
2393
+ diagnostics: diagnostics(),
2394
+ };
1571
2395
  })()`;
1572
2396
  try {
1573
- const contextId = this.getPrimaryContextId();
1574
- const callParams = {
1575
- expression,
1576
- returnByValue: true,
1577
- awaitPromise: true,
1578
- };
1579
- if (contextId !== null)
1580
- callParams.contextId = contextId;
1581
- const res = await this.call('Runtime.evaluate', callParams);
1582
- const value = res?.result?.value;
1583
- if (value?.ok) {
1584
- return { ok: true, model: value.model };
2397
+ const contexts = this.getContexts();
2398
+ const contextIds = [
2399
+ this.getPrimaryContextId(),
2400
+ ...contexts.map((ctx) => ctx.id),
2401
+ ].filter((value, index, arr) => typeof value === 'number' && arr.indexOf(value) === index);
2402
+ const targets = contextIds.length > 0 ? contextIds : [null];
2403
+ let lastError = 'UI operation failed (setUiModel)';
2404
+ for (const contextId of targets) {
2405
+ logger_1.logger.debug(`[CdpService] setUiModel evaluating account=${this.accountName} context=${contextId ?? 'default'} target="${modelName}"`);
2406
+ const params = {
2407
+ expression,
2408
+ returnByValue: true,
2409
+ awaitPromise: true,
2410
+ };
2411
+ if (typeof contextId === 'number')
2412
+ params.contextId = contextId;
2413
+ const res = await this.call('Runtime.evaluate', params);
2414
+ const value = res?.result?.value;
2415
+ if (value?.ok) {
2416
+ logger_1.logger.info(`[CdpService] setUiModel result account=${this.accountName} context=${contextId ?? 'default'} ` +
2417
+ `target="${modelName}" applied="${value.model}" verified=${value.verified === true} ` +
2418
+ `alreadySelected=${value.alreadySelected === true}`);
2419
+ if (value.verified === false) {
2420
+ logger_1.logger.warn(`[CdpService] setUiModel verification did not confirm selection account=${this.accountName} ` +
2421
+ `context=${contextId ?? 'default'} target="${modelName}"`);
2422
+ }
2423
+ return {
2424
+ ok: true,
2425
+ model: value.model,
2426
+ verified: value.verified,
2427
+ alreadySelected: value.alreadySelected,
2428
+ };
2429
+ }
2430
+ if (value?.error) {
2431
+ if (value?.diagnostics) {
2432
+ logger_1.logger.warn(`[CdpService] setUiModel diagnostics account=${this.accountName} context=${contextId ?? 'default'} ` +
2433
+ `target="${modelName}" details=${value.diagnostics}`);
2434
+ }
2435
+ logger_1.logger.warn(`[CdpService] setUiModel context failure account=${this.accountName} context=${contextId ?? 'default'} ` +
2436
+ `target="${modelName}" error="${value.error}"`);
2437
+ lastError = value.error;
2438
+ }
1585
2439
  }
1586
- return { ok: false, error: value?.error || 'UI operation failed (setUiModel)' };
2440
+ logger_1.logger.warn(`[CdpService] setUiModel failed account=${this.accountName} target="${modelName}" error="${lastError}"`);
2441
+ return { ok: false, error: lastError };
1587
2442
  }
1588
2443
  catch (error) {
1589
- return { ok: false, error: error?.message || String(error) };
2444
+ const errorMessage = error?.message || String(error);
2445
+ logger_1.logger.error(`[CdpService] setUiModel exception account=${this.accountName} target="${modelName}": ${errorMessage}`);
2446
+ return { ok: false, error: errorMessage };
1590
2447
  }
1591
2448
  }
1592
2449
  }