kiro-mobile-bridge 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Chrome DevTools Protocol (CDP) connection service
3
+ */
4
+ import http from 'http';
5
+ import { WebSocket } from 'ws';
6
+
7
+ /**
8
+ * Fetch JSON from a CDP endpoint
9
+ * @param {number} port - The port to fetch from
10
+ * @param {string} path - The path to fetch (default: /json/list)
11
+ * @returns {Promise<any>} - Parsed JSON response
12
+ */
13
+ export function fetchCDPTargets(port, path = '/json/list') {
14
+ return new Promise((resolve, reject) => {
15
+ const url = `http://127.0.0.1:${port}${path}`;
16
+
17
+ const req = http.get(url, { timeout: 2000 }, (res) => {
18
+ let data = '';
19
+ res.on('data', chunk => data += chunk);
20
+ res.on('end', () => {
21
+ try {
22
+ resolve(JSON.parse(data));
23
+ } catch (e) {
24
+ reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
25
+ }
26
+ });
27
+ });
28
+
29
+ req.on('error', (err) => reject(new Error(`Failed to fetch ${url}: ${err.message}`)));
30
+ req.on('timeout', () => {
31
+ req.destroy();
32
+ reject(new Error(`Timeout fetching ${url}`));
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Create a CDP connection to a target
39
+ * @param {string} wsUrl - WebSocket debugger URL
40
+ * @returns {Promise<CDPConnection>} - CDP connection object
41
+ */
42
+ export function connectToCDP(wsUrl) {
43
+ return new Promise((resolve, reject) => {
44
+ const ws = new WebSocket(wsUrl);
45
+ let idCounter = 1;
46
+ const pendingCalls = new Map();
47
+ const contexts = [];
48
+ let rootContextId = null;
49
+ let isConnected = false;
50
+
51
+ ws.on('message', (rawMsg) => {
52
+ try {
53
+ const msg = JSON.parse(rawMsg.toString());
54
+
55
+ if (msg.method === 'Runtime.executionContextCreated') {
56
+ const ctx = msg.params.context;
57
+ contexts.push(ctx);
58
+ if (rootContextId === null || ctx.auxData?.isDefault) {
59
+ rootContextId = ctx.id;
60
+ }
61
+ }
62
+
63
+ if (msg.method === 'Runtime.executionContextDestroyed') {
64
+ const ctxId = msg.params.executionContextId;
65
+ const idx = contexts.findIndex(c => c.id === ctxId);
66
+ if (idx !== -1) contexts.splice(idx, 1);
67
+ if (rootContextId === ctxId) {
68
+ rootContextId = contexts.length > 0 ? contexts[0].id : null;
69
+ }
70
+ }
71
+
72
+ if (msg.method === 'Runtime.executionContextsCleared') {
73
+ contexts.length = 0;
74
+ rootContextId = null;
75
+ }
76
+
77
+ if (msg.id !== undefined && pendingCalls.has(msg.id)) {
78
+ const { resolve: res, reject: rej } = pendingCalls.get(msg.id);
79
+ pendingCalls.delete(msg.id);
80
+ if (msg.error) {
81
+ rej(new Error(`CDP Error: ${msg.error.message} (code: ${msg.error.code})`));
82
+ } else {
83
+ res(msg.result);
84
+ }
85
+ }
86
+ } catch (e) {
87
+ console.error('[CDP] Failed to parse message:', e.message);
88
+ }
89
+ });
90
+
91
+ ws.on('open', async () => {
92
+ isConnected = true;
93
+ console.log(`[CDP] Connected to ${wsUrl}`);
94
+
95
+ const cdp = {
96
+ ws,
97
+ contexts,
98
+ get rootContextId() { return rootContextId; },
99
+
100
+ call(method, params = {}) {
101
+ return new Promise((res, rej) => {
102
+ if (!isConnected) {
103
+ rej(new Error('CDP connection is closed'));
104
+ return;
105
+ }
106
+
107
+ const id = idCounter++;
108
+ pendingCalls.set(id, { resolve: res, reject: rej });
109
+ ws.send(JSON.stringify({ id, method, params }));
110
+
111
+ setTimeout(() => {
112
+ if (pendingCalls.has(id)) {
113
+ pendingCalls.delete(id);
114
+ rej(new Error(`CDP call timeout: ${method}`));
115
+ }
116
+ }, 10000);
117
+ });
118
+ },
119
+
120
+ close() {
121
+ isConnected = false;
122
+ for (const [, { reject }] of pendingCalls) {
123
+ reject(new Error('CDP connection closed'));
124
+ }
125
+ pendingCalls.clear();
126
+ ws.terminate();
127
+ }
128
+ };
129
+
130
+ try {
131
+ await cdp.call('Runtime.enable', {});
132
+ await new Promise(r => setTimeout(r, 300));
133
+ console.log(`[CDP] Runtime enabled, found ${contexts.length} context(s)`);
134
+ resolve(cdp);
135
+ } catch (err) {
136
+ cdp.close();
137
+ reject(err);
138
+ }
139
+ });
140
+
141
+ ws.on('error', (err) => {
142
+ console.error(`[CDP] WebSocket error: ${err.message}`);
143
+ isConnected = false;
144
+ reject(err);
145
+ });
146
+
147
+ ws.on('close', () => {
148
+ console.log('[CDP] Connection closed');
149
+ isConnected = false;
150
+ for (const [, { reject }] of pendingCalls) {
151
+ reject(new Error('CDP connection closed'));
152
+ }
153
+ pendingCalls.clear();
154
+ });
155
+ });
156
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Click service - handles UI element clicks via CDP
3
+ */
4
+
5
+ /**
6
+ * Click an element in the Kiro UI via CDP
7
+ * @param {CDPConnection} cdp - CDP connection
8
+ * @param {object} clickInfo - Element identification info
9
+ * @returns {Promise<{success: boolean, matchMethod?: string, error?: string}>}
10
+ */
11
+ export async function clickElement(cdp, clickInfo) {
12
+ const script = `(function() {
13
+ let targetDoc = document;
14
+ const activeFrame = document.getElementById('active-frame');
15
+ if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
16
+
17
+ const info = ${JSON.stringify(clickInfo)};
18
+ let element = null;
19
+ let matchMethod = '';
20
+ const isTabClick = info.isTab || info.role === 'tab';
21
+ const isCloseButton = info.isCloseButton || (info.ariaLabel && info.ariaLabel.toLowerCase() === 'close');
22
+ const isToggle = info.isToggle || info.role === 'switch';
23
+
24
+ // Handle send button
25
+ if (info.isSendButton && !element) {
26
+ const sendSelectors = ['button[data-variant="submit"]', 'svg.lucide-arrow-right', 'button[type="submit"]', 'button[aria-label*="send" i]'];
27
+ for (const sel of sendSelectors) {
28
+ try {
29
+ const el = targetDoc.querySelector(sel);
30
+ if (el) {
31
+ element = el.closest('button') || el;
32
+ if (element && !element.disabled) { matchMethod = 'send-button'; break; }
33
+ }
34
+ } catch(e) {}
35
+ }
36
+ }
37
+
38
+ // Handle toggle/switch
39
+ if (isToggle && !element) {
40
+ if (info.toggleId) {
41
+ element = targetDoc.getElementById(info.toggleId);
42
+ if (element) matchMethod = 'toggle-id';
43
+ }
44
+ if (!element && info.text) {
45
+ const toggles = targetDoc.querySelectorAll('.kiro-toggle-switch, [role="switch"]');
46
+ for (const t of toggles) {
47
+ const label = t.querySelector('label');
48
+ if (label && label.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
49
+ element = t.querySelector('input') || t;
50
+ matchMethod = 'toggle-label';
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ // Handle close button
58
+ if (isCloseButton && !element) {
59
+ const closeButtons = targetDoc.querySelectorAll('[aria-label="close"], .kiro-tabs-item-close, [class*="close"]');
60
+ if (info.parentTabLabel) {
61
+ const searchLabel = info.parentTabLabel.trim().toLowerCase();
62
+ for (const btn of closeButtons) {
63
+ const parentTab = btn.closest('[role="tab"]');
64
+ if (parentTab) {
65
+ const labelEl = parentTab.querySelector('.kiro-tabs-item-label, [class*="label"]');
66
+ const tabLabel = labelEl ? labelEl.textContent.trim().toLowerCase() : '';
67
+ if (tabLabel.includes(searchLabel) || searchLabel.includes(tabLabel)) {
68
+ element = btn;
69
+ matchMethod = 'close-button-by-tab';
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ if (!element && closeButtons.length > 0) {
76
+ for (const btn of closeButtons) {
77
+ const parentTab = btn.closest('[role="tab"]');
78
+ if (parentTab && parentTab.getAttribute('aria-selected') === 'true') {
79
+ element = btn;
80
+ matchMethod = 'close-button-selected';
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Handle tab click
88
+ if (isTabClick && !element) {
89
+ const allTabs = targetDoc.querySelectorAll('[role="tab"]');
90
+ const searchText = (info.tabLabel || info.text || '').trim().toLowerCase();
91
+ for (const tab of allTabs) {
92
+ const labelEl = tab.querySelector('.kiro-tabs-item-label, [class*="label"]');
93
+ const tabText = labelEl ? labelEl.textContent.trim().toLowerCase() : tab.textContent.trim().toLowerCase();
94
+ if (searchText && (tabText.includes(searchText) || searchText.includes(tabText))) {
95
+ element = tab;
96
+ matchMethod = 'tab-label';
97
+ break;
98
+ }
99
+ }
100
+ }
101
+
102
+ // Handle file link
103
+ if (info.isFileLink && info.filePath && !element) {
104
+ const fileName = info.filePath.split('/').pop().split('\\\\').pop();
105
+ const fileSelectors = ['a[href*="' + fileName + '"]', '[data-path*="' + fileName + '"]', 'code', 'span', '[class*="file"]'];
106
+ for (const selector of fileSelectors) {
107
+ const candidates = targetDoc.querySelectorAll(selector);
108
+ for (const el of candidates) {
109
+ const text = (el.textContent || '').trim();
110
+ if (text.includes(info.filePath) || text.includes(fileName)) {
111
+ element = el;
112
+ matchMethod = 'file-link';
113
+ break;
114
+ }
115
+ }
116
+ if (element) break;
117
+ }
118
+ }
119
+
120
+ // Try by aria-label
121
+ if (info.ariaLabel && !element && !isCloseButton) {
122
+ try {
123
+ const candidates = targetDoc.querySelectorAll('[aria-label="' + info.ariaLabel.replace(/"/g, '\\\\"') + '"]');
124
+ for (const c of candidates) {
125
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
126
+ if (!label.includes('close')) { element = c; matchMethod = 'aria-label'; break; }
127
+ }
128
+ } catch(e) {}
129
+ }
130
+
131
+ // Handle history/session list items
132
+ if (info.isHistoryItem && !element) {
133
+ const searchText = (info.text || '').trim().toLowerCase();
134
+
135
+ // Strategy 1: Find all items that look like history entries (contain dates)
136
+ const datePattern = /\\d{1,2}\\/\\d{1,2}\\/\\d{4}|\\d{1,2}:\\d{2}:\\d{2}/;
137
+ const allDivs = targetDoc.querySelectorAll('div, li, article');
138
+ const historyItems = [];
139
+
140
+ for (const item of allDivs) {
141
+ if (item.children.length > 15) continue; // Skip large containers
142
+ const text = item.textContent || '';
143
+ if (datePattern.test(text) && text.length > 20 && text.length < 500) {
144
+ historyItems.push(item);
145
+ }
146
+ }
147
+
148
+ // Find the one matching our search text
149
+ for (const item of historyItems) {
150
+ const itemText = (item.textContent || '').trim().toLowerCase();
151
+ if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
152
+ element = item;
153
+ matchMethod = 'history-item-date';
154
+ break;
155
+ }
156
+ }
157
+
158
+ // Strategy 2: If not found by text, try standard selectors
159
+ if (!element) {
160
+ const historySelectors = [
161
+ '[role="listitem"]',
162
+ '[role="option"]',
163
+ '[class*="history"] > *',
164
+ '[class*="session"] > *',
165
+ '[class*="conversation"] > *',
166
+ '[class*="list-item"]'
167
+ ];
168
+
169
+ for (const selector of historySelectors) {
170
+ try {
171
+ const items = targetDoc.querySelectorAll(selector);
172
+ for (const item of items) {
173
+ const itemText = (item.textContent || '').trim().toLowerCase();
174
+ if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
175
+ element = item;
176
+ matchMethod = 'history-item-selector';
177
+ break;
178
+ }
179
+ }
180
+ if (element) break;
181
+ } catch(e) {}
182
+ }
183
+ }
184
+
185
+ // Strategy 3: Find ANY element with matching text that has cursor:pointer
186
+ if (!element && searchText) {
187
+ const allElements = targetDoc.querySelectorAll('*');
188
+ for (const item of allElements) {
189
+ if (item.children.length > 10) continue;
190
+ const itemText = (item.textContent || '').trim().toLowerCase();
191
+ const firstLine = itemText.split('\\n')[0];
192
+ if (firstLine.includes(searchText) || searchText.includes(firstLine.substring(0, 30))) {
193
+ const style = window.getComputedStyle(item);
194
+ if (style.cursor === 'pointer') {
195
+ element = item;
196
+ matchMethod = 'history-item-pointer';
197
+ break;
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // Try by text content
205
+ if (info.text && info.text.trim() && !element) {
206
+ const searchText = info.text.trim();
207
+ const allElements = targetDoc.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], a, [tabindex="0"], [class*="cursor-pointer"]');
208
+ for (const el of allElements) {
209
+ if (!isCloseButton) {
210
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
211
+ if (ariaLabel.includes('close')) continue;
212
+ }
213
+ const elText = (el.textContent || '').trim();
214
+ if (elText === searchText || elText.includes(searchText) || (elText.length >= 10 && searchText.includes(elText))) {
215
+ element = el;
216
+ matchMethod = 'text-content';
217
+ break;
218
+ }
219
+ }
220
+ }
221
+
222
+ if (!element) return { found: false, error: 'Element not found' };
223
+
224
+ // For history items, click the item itself - NOT child buttons (which might be delete buttons!)
225
+ let clickTarget = element;
226
+
227
+ // DO NOT click child buttons for history items - they are likely delete/close buttons
228
+ // Just click the main item element directly
229
+
230
+ try {
231
+ // Try standard click first
232
+ clickTarget.click();
233
+ return { found: true, clicked: true, matchMethod };
234
+ } catch (e) {
235
+ try {
236
+ // Try full mouse event sequence
237
+ const rect = clickTarget.getBoundingClientRect();
238
+ const centerX = rect.left + rect.width / 2;
239
+ const centerY = rect.top + rect.height / 2;
240
+
241
+ const mouseOpts = {
242
+ bubbles: true,
243
+ cancelable: true,
244
+ view: window,
245
+ clientX: centerX,
246
+ clientY: centerY
247
+ };
248
+
249
+ clickTarget.dispatchEvent(new MouseEvent('mousedown', mouseOpts));
250
+ clickTarget.dispatchEvent(new MouseEvent('mouseup', mouseOpts));
251
+ clickTarget.dispatchEvent(new MouseEvent('click', mouseOpts));
252
+
253
+ return { found: true, clicked: true, matchMethod: matchMethod + '-dispatch' };
254
+ } catch (e2) {
255
+ return { found: true, clicked: false, error: 'Click failed: ' + e2.message };
256
+ }
257
+ }
258
+ })()`;
259
+
260
+ try {
261
+ const result = await cdp.call('Runtime.evaluate', {
262
+ expression: script,
263
+ contextId: cdp.rootContextId,
264
+ returnByValue: true
265
+ });
266
+
267
+ const elementInfo = result.result?.value;
268
+ if (!elementInfo?.found) {
269
+ console.log('[Click] Element not found:', clickInfo.ariaLabel || clickInfo.text);
270
+ return { success: false, error: 'Element not found' };
271
+ }
272
+
273
+ if (elementInfo.clicked) {
274
+ console.log('[Click] Clicked via', elementInfo.matchMethod);
275
+ return { success: true, matchMethod: elementInfo.matchMethod };
276
+ }
277
+ return { success: false, error: elementInfo.error || 'Click failed' };
278
+ } catch (err) {
279
+ console.error('[Click] CDP error:', err.message);
280
+ return { success: false, error: err.message };
281
+ }
282
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Message injection service - sends messages to Kiro chat via CDP
3
+ */
4
+
5
+ /**
6
+ * Create script to inject message into chat input
7
+ * @param {string} messageText - Message to inject
8
+ * @returns {string} - JavaScript expression
9
+ */
10
+ function createInjectScript(messageText) {
11
+ const escaped = messageText.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
12
+
13
+ return `(async () => {
14
+ const text = '${escaped}';
15
+ let targetDoc = document;
16
+ const activeFrame = document.getElementById('active-frame');
17
+ if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
18
+
19
+ // Find the CHAT INPUT editor specifically (not any contenteditable in the page)
20
+ // Look for the input area at the bottom of the chat, not message bubbles
21
+ let editor = null;
22
+
23
+ // Strategy 1: Find by common chat input container patterns
24
+ const inputContainerSelectors = [
25
+ '[class*="chat-input"]',
26
+ '[class*="message-input"]',
27
+ '[class*="composer"]',
28
+ '[class*="input-area"]',
29
+ '[class*="InputArea"]',
30
+ 'form[class*="chat"]',
31
+ '[data-testid*="input"]'
32
+ ];
33
+
34
+ for (const containerSel of inputContainerSelectors) {
35
+ const container = targetDoc.querySelector(containerSel);
36
+ if (container) {
37
+ const editorInContainer = container.querySelector('.tiptap.ProseMirror[contenteditable="true"], [data-lexical-editor="true"][contenteditable="true"], [contenteditable="true"], textarea');
38
+ if (editorInContainer && editorInContainer.offsetParent !== null) {
39
+ editor = editorInContainer;
40
+ break;
41
+ }
42
+ }
43
+ }
44
+
45
+ // Strategy 2: Find TipTap/ProseMirror editors that are visible and near a submit button
46
+ if (!editor) {
47
+ const allEditors = [...targetDoc.querySelectorAll('.tiptap.ProseMirror[contenteditable="true"]')].filter(el => el.offsetParent !== null);
48
+ for (const ed of allEditors) {
49
+ // Check if there's a submit button nearby (sibling or in same parent form)
50
+ const parent = ed.closest('form') || ed.parentElement?.parentElement?.parentElement;
51
+ if (parent) {
52
+ const hasSubmit = parent.querySelector('button[data-variant="submit"], button[type="submit"], svg.lucide-arrow-right');
53
+ if (hasSubmit) { editor = ed; break; }
54
+ }
55
+ }
56
+ // Fallback to last visible TipTap editor
57
+ if (!editor && allEditors.length > 0) editor = allEditors.at(-1);
58
+ }
59
+
60
+ // Strategy 3: Find Lexical editors
61
+ if (!editor) {
62
+ const lexicalEditors = [...targetDoc.querySelectorAll('[data-lexical-editor="true"][contenteditable="true"]')].filter(el => el.offsetParent !== null);
63
+ for (const ed of lexicalEditors) {
64
+ const parent = ed.closest('form') || ed.parentElement?.parentElement?.parentElement;
65
+ if (parent) {
66
+ const hasSubmit = parent.querySelector('button[data-variant="submit"], button[type="submit"]');
67
+ if (hasSubmit) { editor = ed; break; }
68
+ }
69
+ }
70
+ if (!editor && lexicalEditors.length > 0) editor = lexicalEditors.at(-1);
71
+ }
72
+
73
+ // Strategy 4: Generic contenteditable (last resort)
74
+ if (!editor) {
75
+ const editables = [...targetDoc.querySelectorAll('[contenteditable="true"]')].filter(el => el.offsetParent !== null);
76
+ editor = editables.at(-1);
77
+ }
78
+
79
+ // Strategy 5: Textarea fallback
80
+ if (!editor) {
81
+ const textareas = [...targetDoc.querySelectorAll('textarea')].filter(el => el.offsetParent !== null);
82
+ editor = textareas.at(-1);
83
+ }
84
+
85
+ if (!editor) return { ok: false, error: 'editor_not_found' };
86
+
87
+ const isTextarea = editor.tagName.toLowerCase() === 'textarea';
88
+ const isProseMirror = editor.classList.contains('ProseMirror') || editor.classList.contains('tiptap');
89
+ const isLexical = editor.hasAttribute('data-lexical-editor');
90
+
91
+ // Focus the editor first
92
+ editor.focus();
93
+ await new Promise(r => setTimeout(r, 50));
94
+
95
+ if (isTextarea) {
96
+ // Textarea: simple value assignment
97
+ editor.value = text;
98
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
99
+ } else if (isProseMirror) {
100
+ // ProseMirror/TipTap: Clear and insert via DOM manipulation
101
+ // This works because TipTap syncs DOM changes to its state
102
+ editor.innerHTML = '';
103
+ const p = targetDoc.createElement('p');
104
+ p.textContent = text;
105
+ editor.appendChild(p);
106
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
107
+ } else if (isLexical) {
108
+ // Lexical: Must use execCommand to properly update internal state
109
+ // Lexical listens to beforeinput/input events from execCommand
110
+
111
+ // First, select all content
112
+ const selection = targetDoc.getSelection();
113
+ const range = targetDoc.createRange();
114
+ range.selectNodeContents(editor);
115
+ selection.removeAllRanges();
116
+ selection.addRange(range);
117
+
118
+ // Delete existing content
119
+ targetDoc.execCommand('delete', false, null);
120
+
121
+ // Wait for Lexical to process the deletion
122
+ await new Promise(r => setTimeout(r, 30));
123
+
124
+ // Insert new text using execCommand (Lexical intercepts this)
125
+ const inserted = targetDoc.execCommand('insertText', false, text);
126
+
127
+ if (!inserted) {
128
+ // Fallback: Try setting textContent and dispatching events
129
+ editor.textContent = text;
130
+ editor.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: text }));
131
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
132
+ }
133
+ } else {
134
+ // Generic contenteditable
135
+ const selection = targetDoc.getSelection();
136
+ const range = targetDoc.createRange();
137
+ range.selectNodeContents(editor);
138
+ selection.removeAllRanges();
139
+ selection.addRange(range);
140
+
141
+ targetDoc.execCommand('delete', false, null);
142
+
143
+ let inserted = false;
144
+ try { inserted = !!targetDoc.execCommand('insertText', false, text); } catch (e) {}
145
+
146
+ if (!inserted) {
147
+ editor.textContent = text;
148
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
149
+ }
150
+ }
151
+
152
+ // Wait for editor state to sync
153
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
154
+ await new Promise(r => setTimeout(r, 100));
155
+
156
+ // Find and click submit button
157
+ const submitButton = targetDoc.querySelector('button[data-variant="submit"]:not([disabled])') ||
158
+ targetDoc.querySelector('svg.lucide-arrow-right')?.closest('button:not([disabled])') ||
159
+ targetDoc.querySelector('button[type="submit"]:not([disabled])') ||
160
+ targetDoc.querySelector('button[aria-label*="send" i]:not([disabled])');
161
+
162
+ if (submitButton) {
163
+ submitButton.click();
164
+ return { ok: true, method: 'click_submit' };
165
+ }
166
+
167
+ // Fallback: Enter key
168
+ editor.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13 }));
169
+ return { ok: true, method: 'enter_key' };
170
+ })()`;
171
+ }
172
+
173
+ /**
174
+ * Inject a message into the chat via CDP
175
+ * @param {CDPConnection} cdp - CDP connection
176
+ * @param {string} message - Message text
177
+ * @returns {Promise<{success: boolean, method?: string, error?: string}>}
178
+ */
179
+ export async function injectMessage(cdp, message) {
180
+ if (!cdp.rootContextId) {
181
+ return { success: false, error: 'No execution context available' };
182
+ }
183
+
184
+ try {
185
+ const result = await cdp.call('Runtime.evaluate', {
186
+ expression: createInjectScript(message),
187
+ contextId: cdp.rootContextId,
188
+ returnByValue: true,
189
+ awaitPromise: true
190
+ });
191
+
192
+ if (result.exceptionDetails) {
193
+ return { success: false, error: result.exceptionDetails.exception?.description || 'Unknown error' };
194
+ }
195
+
196
+ const value = result.result?.value;
197
+ if (value?.ok) {
198
+ console.log(`[Inject] Message sent via ${value.method}`);
199
+ return { success: true, method: value.method };
200
+ }
201
+ return { success: false, error: value?.error || 'Injection failed' };
202
+ } catch (err) {
203
+ console.error('[Inject] CDP call failed:', err.message);
204
+ return { success: false, error: err.message };
205
+ }
206
+ }