diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f6858b2a..31bb9908f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ The independently published `fsb-mcp-server` npm package keeps its own semver ch
### Fixed
+- **The bridge no longer dies until you restart Chrome.** The local bridge keeps a single extension socket, and it was freed only when that socket closed itself. An extension whose service worker died without the socket closing therefore held the slot indefinitely, and every legitimate reconnect was refused — one episode in the field ran 13,328 refusals over roughly 25 hours, ending only when the browser quit. The extension could not learn from any of it: its `onclose` handler discarded the close code, and because the WebSocket upgrade *succeeded* before the refusal arrived, `onopen` reset the reconnect backoff on every cycle, so it retried at a flat two seconds forever. The bridge now reaps a socket that stops answering both protocol pings and its own heartbeat; the extension reads the close code, escalates its backoff toward the 30-second ceiling instead of flattening it, and drops a pairing credential the bridge has explicitly rejected rather than replaying it until the browser exits.
+- **Keepalive actually detects a dead peer now.** The extension sent a ping every 25 seconds and never looked at the reply; the reply itself was discarded by a handler that only accepted the delegation heartbeat's three-key form. Neither end could notice a half-open socket. Unanswered pings are now counted and close the socket, which hands it to the existing reconnect path.
+- **Servers exit when their host does.** `fsb-mcp-server` listened only for SIGTERM and SIGINT, so a host that exits by closing the pipe left a fully working server running forever. Because the bridge port is claimed once by a bind race and never re-attempted, the bridge owner drifted into being the oldest surviving orphan rather than a process anyone was still talking to. The server now shuts down when its stdio pipe closes.
+- **A pairing mismatch says so.** An extension id that does not match the pinned pairing was refused with a bare HTTP 403 — no WebSocket, no close code, no reason — which is why the failure so often ended in a reinstall that could not fix it. The refusal now names both origins and the `pair --reset` cure, and `doctor` reports an authorization layer instead of blaming the browser.
+- **The bridge keeps a journal.** Refusals, revocations, replacements, reaps, and closes are recorded in `~/.fsb/agent-runtime/bridge-events.jsonl`, coalesced so a retry loop leaves one line a minute rather than tens of thousands. Previously every bridge diagnostic went to the stderr of whichever server won the port race, which was frequently a session that had already exited.
+- **`sw_evicted` no longer hides the real cause.** Authorization failures were reported to callers as a service-worker eviction, sending everyone to restart the browser. The recovery is unchanged — the task really was interrupted — but the response now carries a `disconnect_reason` that distinguishes a revoked credential from an evicted worker.
+
- **Dashboard QR pairing failures are visible again.** Scanning a pairing code that was expired, already used, or otherwise rejected reported nothing at all: the "Connecting..." state replaced the scan panel markup, so the error was written into a node that had already been removed from the DOM, and the dashboard then switched to the Paste Key tab. Every failure now renders its localized reason, keeps the user on the Scan tab, and restarts the camera so a regenerated code can be scanned directly.
## v0.9.91 — Version Metadata Alignment — 2026-07-14
diff --git a/README.md b/README.md
index 910b2185c..6ab9ff1c5 100644
--- a/README.md
+++ b/README.md
@@ -231,6 +231,8 @@ Start with simple tasks such as:
Reload the extension from `chrome://extensions/` after local code changes. Reload open tabs after the extension reloads so content scripts re-inject.
+For an unpacked build, record the ID on `chrome://extensions/`. A different install path or profile can produce a different ID; if `doctor` reports `ORIGIN_PIN_MISMATCH`, run `npx -y fsb-mcp-server@latest pair --reset` and pair the new installation. One browser profile attaches to the local bridge at a time. `doctor` and `status` show the attached extension ID, version, install instance, connection time, and normal-window count. The MCP server and extension have independent versions. Branded Chrome 137 removed the `--load-extension` flag; use **Load unpacked** in Chrome, or use [Chrome for Testing or Chromium when a command-line flag is required](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ).
+
### First Run Checklist
1. Open the FSB control panel from the extension.
@@ -287,6 +289,8 @@ Optional Streamable HTTP mode exposes:
http://127.0.0.1:7226/mcp
```
+The HTTP endpoint binds to loopback only and rejects requests with an `Origin` header or a foreign `Host`. Use the printed local endpoint from an MCP client. If a tab stops responding, page reads return `PAGE_UNRESPONSIVE`; `navigate` and `close_tab` remain available. Inspect page state before repeating any action reported as `outcome: "unknown"` and `mayHaveExecuted: true`.
+
### One Command Install
```bash
diff --git a/extension/README.md b/extension/README.md
index fa55b8f6f..3bfbb282e 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -13,6 +13,10 @@
After code changes, reload the extension from `chrome://extensions` and refresh any open tabs so content scripts re-inject.
+Copy the extension ID shown on `chrome://extensions` when pairing an unpacked build. Its ID can differ from the Chrome Web Store ID and can change if you reinstall it from another directory. Run `npx -y fsb-mcp-server@latest pair --reset` if `doctor` reports `ORIGIN_PIN_MISMATCH`, then pair the new installation. `doctor` also shows a persistent install instance ID, the extension version, and the normal-window count so you can identify which browser profile holds the bridge. One extension/profile attaches to the local bridge at a time; another profile may replace it. The MCP server and extension have independent version numbers.
+
+In branded Chrome 137 and later, the `--load-extension` command-line flag no longer loads unpacked extensions. Use the **Load unpacked** button above. [Chrome for Testing and Chromium retain the flag](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ).
+
## Google Sheets Development
Google Sheets capabilities reuse an already signed-in, agent-owned spreadsheet tab. They require no Google Cloud client ID, consent prompt, token setup, or Sheets-specific MCP update. Reload the unpacked extension after changes, refresh the open Sheet, and reconnect the existing MCP bridge. See [Google Sheets signed-in session integration](../docs/google-sheets-api.md) for the bounded operation contract and UAT gates.
diff --git a/extension/ai/agent-loop.js b/extension/ai/agent-loop.js
index 540ed2f5d..0cea8be28 100644
--- a/extension/ai/agent-loop.js
+++ b/extension/ai/agent-loop.js
@@ -2424,8 +2424,17 @@ async function runAgentIteration(sessionId, options) {
? getGuideForTask('', 'https://' + domain)
: null;
if (guide) {
+ var guideGuidance = JSON.stringify({
+ selectors: guide.selectors || {},
+ workflows: {
+ createPost: guide.workflows && guide.workflows.createPost,
+ replyToPost: guide.workflows && guide.workflows.replyToPost
+ },
+ warnings: Array.isArray(guide.warnings) ? guide.warnings.slice(0, 6) : [],
+ guidance: typeof guide.guidance === 'string' ? guide.guidance.slice(0, 1600) : ''
+ }).slice(0, 5000);
result = { success: true, hadEffect: false, error: null, navigationTriggered: false,
- result: { domain: domain, site: guide.site || guide.name || domain, guidance: JSON.stringify(guide.selectors || guide) } };
+ result: { domain: domain, site: guide.site || guide.name || domain, guidance: guideGuidance } };
} else {
result = { success: true, hadEffect: false, error: null, navigationTriggered: false,
result: { domain: domain, guidance: 'No site guide available for ' + domain + '. Use get_page_snapshot and get_dom_snapshot to discover elements.' } };
diff --git a/extension/ai/tool-definitions.js b/extension/ai/tool-definitions.js
index b37965e00..6516d1e81 100644
--- a/extension/ai/tool-definitions.js
+++ b/extension/ai/tool-definitions.js
@@ -242,12 +242,13 @@ const TOOL_REGISTRY = [
withVisualSessionFields({
name: 'type_text',
- description: 'Type text into an input field by selector. When to use: to fill text inputs, search boxes, or text areas. Use clear_input first if the field already has text. Returns confirmation of typed text. Related: clear_input (clear field before typing), press_enter (submit after typing), get_dom_snapshot (find input selectors). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.',
+ description: 'Replace text in an editable field by selector. Set clear_first to false to append. Returns the rendered text after insertion. Related: clear_input, press_enter, get_dom_snapshot. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector or element ref for the input field (e.g., "#email", "input[name=search]", or "e12" from get_dom_snapshot)' },
text: { type: 'string', description: 'Text to type into the field' },
+ clear_first: { type: 'boolean', description: 'Replace existing text (default true); false appends at the end.' },
tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' }
},
required: ['selector', 'text']
@@ -869,11 +870,13 @@ const TOOL_REGISTRY = [
withVisualSessionFields({
name: 'insert_text',
- description: 'Insert text at the current cursor position via CDP Input.insertText. Bypasses DOM event dispatch and directly inserts into the focused element. When to use: for canvas-based editors (Excalidraw, Google Docs, Slack) where type_text does not work because there is no real input element. The element must already be focused or in edit mode (use double_click_at or click_at first). Related: type_text (for real DOM input fields), double_click_at (enter edit mode in canvas editors before inserting text), click_at (focus canvas element before inserting). Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64). Pass tab_id only when this agent owns multiple tabs; auto-resolves otherwise.',
+ description: 'Insert text through CDP at the caret by default. Use position end to append or replace_all to replace an editable field; selector can identify that field. Canvas editors require focus first. Related: type_text, double_click_at, click_at. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string', description: 'Text to insert at current cursor position via CDP' },
+ position: { type: 'string', enum: ['caret', 'end', 'replace_all'], description: 'Insertion position; defaults to caret.' },
+ selector: { type: 'string', description: 'Optional CSS selector identifying exactly one editable field.' },
tab_id: { type: 'number', description: 'Optional. Tab id this action targets. Omit when the calling agent owns exactly one tab; pass to disambiguate when the agent owns multiple. Single-tab agents and legacy popup/sidepanel/autopilot do not need to pass this.' }
},
required: ['text']
@@ -1459,6 +1462,13 @@ function getToolByName(name) {
return TOOL_REGISTRY.find(t => t.name === name) || null;
}
+/** Resolve the public MCP name or the content/CDP verb sent on the bridge. */
+function getToolByNameOrVerb(nameOrVerb) {
+ const name = typeof nameOrVerb === 'string' ? nameOrVerb.trim() : '';
+ if (!name) return null;
+ return getToolByName(name) || getToolByName(_iconVerbMap().get(name));
+}
+
/**
* Get all read-only tools (those that bypass the mutation queue).
* @returns {ToolDefinition[]} Array of read-only tool definitions
@@ -1518,7 +1528,7 @@ function resolveIconActivity(nameOrVerb) {
if (!raw) return 'sweep';
const name = _iconVerbMap().get(raw) || raw;
if (name === 'invoke_capability') return null;
- const def = getToolByName(name);
+ const def = getToolByNameOrVerb(raw);
if ((def && def._readOnly === true) || ICON_READ_ONLY_EXTRAS.has(name)) return 'orbit';
return 'sweep';
}
@@ -1533,6 +1543,7 @@ if (typeof module !== 'undefined' && module.exports) {
TOOL_REGISTRY,
resolveIconActivity,
getToolByName,
+ getToolByNameOrVerb,
getReadOnlyTools,
getToolsByRoute,
VISUAL_SESSION_FIELDS,
diff --git a/extension/background.js b/extension/background.js
index 773ed43fa..307bb92dd 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -1149,12 +1149,15 @@ async function sendSessionStatus(tabId, statusData) {
...(statusData?.sessionId ? { sessionId: statusData.sessionId } : {})
};
try {
- await chrome.tabs.sendMessage(tabId, payload, { frameId: 0 });
+ await Promise.race([chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Status probe timed out')), 750))]);
} catch (firstErr) {
// First attempt failed -- try re-injecting the content script and retry once
try {
- await ensureContentScriptInjected(tabId, 1);
- await chrome.tabs.sendMessage(tabId, payload, { frameId: 0 });
+ await Promise.race([ensureContentScriptInjected(tabId, 1),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Status reinjection timed out')), 1000))]);
+ await Promise.race([chrome.tabs.sendMessage(tabId, payload, { frameId: 0 }),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Status retry timed out')), 750))]);
} catch (retryErr) {
automationLogger.debug('sendSessionStatus delivery failed', {
tabId, phase: statusData.phase, error: retryErr.message
@@ -5329,6 +5332,7 @@ chrome.runtime.onConnect.addListener((port) => {
});
port.onDisconnect.addListener(() => {
+ if (contentScriptPorts.get(tabId)?.port !== port) return;
contentScriptPorts.delete(tabId);
contentScriptReadyStatus.delete(tabId);
contentScriptHealth.delete(tabId);
@@ -5842,6 +5846,30 @@ function getContentScriptDiagnosticsForTab(tabId, activeTabUrl = '') {
};
}
+let mcpInstallInstanceIdPromise = null;
+async function getMcpAttachmentMetadata() {
+ if (!mcpInstallInstanceIdPromise) {
+ mcpInstallInstanceIdPromise = (async () => {
+ const key = 'mcpInstallInstanceId';
+ const stored = await chrome.storage.local.get(key);
+ if (typeof stored[key] === 'string' && stored[key]) return stored[key];
+ const id = crypto.randomUUID();
+ await chrome.storage.local.set({ [key]: id });
+ return id;
+ })().catch((error) => { mcpInstallInstanceIdPromise = null; throw error; });
+ }
+ const [installInstanceId, windows] = await Promise.all([
+ mcpInstallInstanceIdPromise,
+ chrome.windows.getAll({ windowTypes: ['normal'] })
+ ]);
+ return {
+ extensionId: chrome.runtime.id,
+ extensionVersion: chrome.runtime.getManifest().version,
+ installInstanceId,
+ normalWindowCount: windows.length
+ };
+}
+
async function collectMcpDiagnosticsSnapshot() {
let activeTab = {
id: null,
@@ -5867,6 +5895,21 @@ async function collectMcpDiagnosticsSnapshot() {
}
} catch (_error) {}
+ let attachment = null;
+ let tabsSummary = { totalTabs: 0, activeTabId: activeTab.id };
+ try {
+ attachment = await getMcpAttachmentMetadata();
+ const [windows, tabs] = await Promise.all([
+ chrome.windows.getAll({ windowTypes: ['normal'] }),
+ chrome.tabs.query({})
+ ]);
+ const normalWindowIds = new Set(windows.map(window => window.id));
+ tabsSummary = {
+ totalTabs: tabs.filter(tab => normalWindowIds.has(tab.windowId)).length,
+ activeTabId: activeTab.id
+ };
+ } catch (_error) {}
+
let bridgeClient = null;
try {
if (chrome.storage?.session?.get) {
@@ -5875,16 +5918,43 @@ async function collectMcpDiagnosticsSnapshot() {
}
} catch (_error) {}
+ if (attachment && bridgeClient) {
+ attachment.connectedAt = bridgeClient.lastConnectedAt || null;
+ }
+
return {
success: true,
activeTab,
contentScript: getContentScriptDiagnosticsForTab(activeTab.id, activeTab.url),
- bridgeClient
+ bridgeClient,
+ attachment,
+ tabsSummary
};
}
// Enhanced content script injection with retry logic and page load checks
+const contentScriptInjectionFlights = new Map();
async function ensureContentScriptInjected(tabId, maxRetries = 3) {
+ let flight = contentScriptInjectionFlights.get(tabId);
+ if (!flight) {
+ flight = ensureContentScriptInjectedUnlocked(tabId, maxRetries);
+ contentScriptInjectionFlights.set(tabId, flight);
+ flight.finally(() => {
+ if (contentScriptInjectionFlights.get(tabId) === flight) contentScriptInjectionFlights.delete(tabId);
+ }).catch(() => {});
+ }
+ return Promise.race([flight,
+ new Promise((_, reject) => setTimeout(() => {
+ // A flight whose Chrome callback never arrives must not pin the tab;
+ // the next caller starts a fresh injection.
+ if (contentScriptInjectionFlights.get(tabId) === flight) contentScriptInjectionFlights.delete(tabId);
+ const error = new Error('Content script injection timed out');
+ error.code = 'PAGE_UNRESPONSIVE';
+ reject(error);
+ }, 12000))]);
+}
+
+async function ensureContentScriptInjectedUnlocked(tabId, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Wait for page to be fully loaded before health check
@@ -5909,6 +5979,11 @@ async function ensureContentScriptInjected(tabId, maxRetries = 3) {
// Check port connection first - most reliable indicator
const portInfo = contentScriptPorts.get(tabId);
+ if (portInfo && Date.now() - portInfo.lastHeartbeat >= 10000) {
+ try { portInfo.port.disconnect(); } catch (_error) {}
+ contentScriptPorts.delete(tabId);
+ contentScriptReadyStatus.delete(tabId);
+ }
if (portInfo && Date.now() - portInfo.lastHeartbeat < 10000) {
automationLogger.logComm(null, 'health', 'port_healthy', true, { tabId, source: 'port' });
return true;
@@ -10008,6 +10083,9 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) {
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ // Per attempt: an earlier dispatch only reaches a retry after the
+ // no-receiving-end error, which proves it was not delivered.
+ let messageDispatched = false;
try {
// Check content script health before every attempt (not just the first)
const isHealthy = await checkContentScriptHealth(tabId);
@@ -10016,8 +10094,15 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) {
await ensureContentScriptInjected(tabId);
}
+ if (message.action === 'executeAction' && Number.isFinite(message._fsbDeadlineAt)
+ && Date.now() >= message._fsbDeadlineAt) {
+ return { success: false, errorCode: 'PAGE_UNRESPONSIVE', outcome: 'failed',
+ mayHaveExecuted: false, error: 'The page did not respond before action delivery.' };
+ }
+
// CRITICAL: Use frameId: 0 to target ONLY the main frame
// This prevents responding from iframes (like Google's RotateCookiesPage iframe)
+ messageDispatched = true;
const response = await chrome.tabs.sendMessage(tabId, message, { frameId: 0 });
// Success - reset health tracking
@@ -10032,6 +10117,18 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) {
} catch (error) {
const failureType = classifyFailure(error, message);
automationLogger.logComm(null, 'send', message.action || 'unknown', false, { tabId, attempt, failureType, error: error.message });
+
+ // A closed port can mean the page acted and navigated before replying.
+ // Only the explicit "no receiving end" error proves non-delivery.
+ if (message.action === 'executeAction' && messageDispatched
+ && !/receiving end does not exist/i.test(error.message || '')) {
+ return {
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
+ error: 'The action may have run before the content-script connection closed. Inspect the page before retrying.'
+ };
+ }
// Update health tracking
const health = contentScriptHealth.get(tabId) || { failures: 0 };
@@ -10113,10 +10210,14 @@ async function sendMessageWithRetry(tabId, message, maxRetries = 3) {
// Alternative action strategies for failed operations
async function tryAlternativeAction(sessionId, originalAction, originalError) {
+ if (originalError?.mayHaveExecuted || originalError?.outcome === 'unknown') return null;
const session = activeSessions.get(sessionId);
if (!session) return null;
const { tool, params } = originalAction;
+ if (['type', 'type_text', 'insert_text', 'press_key', 'press_enter'].includes(tool)) {
+ return null;
+ }
const alternatives = [];
// Type action alternatives
@@ -10135,14 +10236,9 @@ async function tryAlternativeAction(sessionId, originalAction, originalError) {
// Click action alternatives
if (tool === 'click') {
- alternatives.push(
- // Try different click methods
- { tool: 'doubleClick', params, description: `Try double-click instead` },
- { tool: 'rightClick', params, description: `Try right-click to trigger context` },
- // Try hovering first
- { tool: 'hover', params, description: `Hover before clicking` },
- { tool: 'click', params: { ...params, forceClick: true }, description: `Force click ignoring visibility` }
- );
+ // A dispatched click may have mutated the page even without a visible
+ // response. The caller must inspect state before choosing another action.
+ return null;
}
// Selector alternatives for any action with selector
@@ -19106,9 +19202,93 @@ async function handleCDPInsertText(request, sender, sendResponse) {
return runLegacyCdpMessageWithLease(handleCDPInsertTextUnlocked, request, sender, sendResponse);
}
+async function prepareCdpTextTarget(tabId, selector, position) {
+ if (!selector && position === 'caret') return { success: true };
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId },
+ func: (css, placement) => {
+ let element;
+ try {
+ if (css) {
+ const matches = Array.from(document.querySelectorAll(css));
+ if (matches.length !== 1) return { success: false, error: 'Selector must identify exactly one editable field' };
+ element = matches[0];
+ } else {
+ element = document.activeElement;
+ }
+ } catch (error) {
+ return { success: false, error: `Invalid editable selector: ${error.message}` };
+ }
+ // Canvas editors (Google Docs) keep focus in a nested text-event frame
+ // this script cannot reach. The key and insert events go to that focused
+ // frame, so its own editor handles the selection.
+ if (!css && element?.tagName === 'IFRAME') return { success: true };
+ if (element?.isContentEditable) {
+ element = element.closest('[contenteditable="true"], [contenteditable=""]') || element;
+ } else if (element && !['INPUT', 'TEXTAREA'].includes(element.tagName)) {
+ const candidates = element.querySelectorAll('input:not([type="hidden"]), textarea, [contenteditable="true"], [contenteditable=""]');
+ if (candidates.length !== 1) return { success: false, error: 'Target is not one editable field' };
+ element = candidates[0];
+ }
+ if (!element || (!['INPUT', 'TEXTAREA'].includes(element.tagName) && !element.isContentEditable)) {
+ return { success: false, error: 'Target is not editable' };
+ }
+ element.focus();
+ const previous = ['INPUT', 'TEXTAREA'].includes(element.tagName) ? element.value : element.innerText;
+ if (placement === 'end' || placement === 'replace_all') {
+ if (typeof element.setSelectionRange === 'function') {
+ const at = placement === 'end' ? element.value.length : 0;
+ element.setSelectionRange(at, placement === 'end' ? at : element.value.length);
+ } else {
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ if (placement === 'end') range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+ }
+ return { success: true, previous };
+ },
+ args: [selector || null, position]
+ });
+ return result?.result || { success: false, error: 'Unable to inspect editable field' };
+}
+
+async function dispatchCdpTextInsertion(tabId, text, position = 'caret', selector = null) {
+ if (!['caret', 'end', 'replace_all'].includes(position)) {
+ return { success: false, error: 'Invalid insertion position' };
+ }
+ const prepared = await prepareCdpTextTarget(tabId, selector, position);
+ if (!prepared.success) return prepared;
+ try {
+ if (position === 'replace_all') {
+ const isMac = typeof navigator !== 'undefined' &&
+ (/Macintosh/.test(navigator.userAgent || '') || /Mac/.test(navigator.platform || ''));
+ const modifiers = isMac ? 4 : 2;
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
+ type: 'keyDown', modifiers, key: 'a', code: 'KeyA',
+ windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65,
+ commands: ['selectAll']
+ });
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
+ type: 'keyUp', modifiers, key: 'a', code: 'KeyA',
+ windowsVirtualKeyCode: 65, nativeVirtualKeyCode: 65
+ });
+ }
+ await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
+ } catch (error) {
+ // Input may already have reached the page, so a partial edit is possible.
+ if (error && typeof error === 'object') error.mayHaveExecuted = true;
+ throw error;
+ }
+ return { success: true, text, length: text.length, position, mayHaveExecuted: true };
+}
+
async function handleCDPInsertTextUnlocked(request, sender, sendResponse) {
const tabId = sender.tab?.id;
- const { text, clearFirst } = request;
+ const { text, clearFirst, selector } = request;
+ const position = request.position || (clearFirst ? 'replace_all' : 'caret');
if (!tabId) {
sendResponse({ success: false, error: 'No tab ID available' });
@@ -19121,6 +19301,7 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) {
}
let debuggerAttached = false;
+ let textInserted = false;
try {
automationLogger.logActionExecution(null, 'cdpInsertText', 'start', { tabId, textLength: text.length });
@@ -19128,67 +19309,14 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) {
await attachFsbDebugger(tabId, 'cdpInsertText');
debuggerAttached = true;
- // If clearFirst is requested, select all and delete
- if (clearFirst) {
- // Detect platform: modifier 4 = Meta (Cmd) on macOS, modifier 2 = Ctrl on others
- const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac');
- const selectAllModifier = isMac ? 4 : 2;
-
- // Select all text in focused element
- await chrome.debugger.sendCommand(
- { tabId },
- 'Input.dispatchKeyEvent',
- {
- type: 'keyDown',
- modifiers: selectAllModifier,
- key: 'a',
- code: 'KeyA'
- }
- );
- await chrome.debugger.sendCommand(
- { tabId },
- 'Input.dispatchKeyEvent',
- {
- type: 'keyUp',
- modifiers: selectAllModifier,
- key: 'a',
- code: 'KeyA'
- }
- );
-
- // Delay for selection -- Monaco needs ~200ms to process Ctrl+A and update its internal model
- await new Promise(r => setTimeout(r, 200));
-
- // Delete selected text
- await chrome.debugger.sendCommand(
- { tabId },
- 'Input.dispatchKeyEvent',
- {
- type: 'keyDown',
- key: 'Backspace',
- code: 'Backspace'
- }
- );
- await chrome.debugger.sendCommand(
- { tabId },
- 'Input.dispatchKeyEvent',
- {
- type: 'keyUp',
- key: 'Backspace',
- code: 'Backspace'
- }
- );
-
- // Delay for deletion -- Monaco needs time to clear its buffer before accepting new input
- await new Promise(r => setTimeout(r, 200));
+ const inserted = await dispatchCdpTextInsertion(tabId, text, position, selector);
+ if (!inserted.success) {
+ await chrome.debugger.detach({ tabId });
+ debuggerAttached = false;
+ sendResponse(inserted);
+ return;
}
-
- // Use Input.insertText for reliable text insertion
- await chrome.debugger.sendCommand(
- { tabId },
- 'Input.insertText',
- { text }
- );
+ textInserted = true;
// Detach debugger
await chrome.debugger.detach({ tabId });
@@ -19214,7 +19342,12 @@ async function handleCDPInsertTextUnlocked(request, sender, sendResponse) {
}
}
- sendResponse(cdpFailureResult(error, { method: 'cdp' }));
+ // Attach and target failures happen before any input and keep their
+ // retryable classification.
+ const failure = cdpFailureResult(error, { method: 'cdp' });
+ sendResponse(textInserted || error?.mayHaveExecuted
+ ? { ...failure, outcome: 'unknown', mayHaveExecuted: true, retryable: false }
+ : failure);
}
}
@@ -19773,13 +19906,21 @@ async function executeCDPToolDirect(request, tabId) {
return await leaseApi.run(
tabId,
() => executeCDPToolDirectUnlocked(request, tabId),
- { timeoutMs: 10000 }
+ { timeoutMs: 10000, holdMs: cdpToolLeaseHoldMs(request.params) }
);
} catch (error) {
return cdpFailureResult(error);
}
}
+// Hold and drag verbs run for as long as the caller asks; the lease watchdog
+// has to outlast them.
+function cdpToolLeaseHoldMs(params) {
+ const p = params || {};
+ const perStepMs = (Number(p.stepDelayMs) || Number(p.maxDelayMs) || 0) + 50;
+ return 20000 + (Number(p.holdMs) || 0) + (Number(p.steps) || 0) * perStepMs;
+}
+
async function executeCDPToolDirectUnlocked(request, tabId) {
const { tool: verb, params } = request;
@@ -20035,41 +20176,21 @@ async function executeCDPToolDirectUnlocked(request, tabId) {
// cdpInsertText: Input.insertText with optional clearFirst
// -----------------------------------------------------------------
case 'cdpInsertText': {
- const { text, clearFirst } = params || {};
+ const { text, clearFirst, selector } = params || {};
+ const position = params?.position || (clearFirst ? 'replace_all' : 'caret');
if (!text) {
return { success: false, error: 'cdpInsertText: no text provided' };
}
let debuggerAttached = false;
+ let textInserted = false;
try {
automationLogger.logActionExecution(null, 'cdpInsertText', 'start', { tabId, textLength: text.length });
await attachDebugger();
debuggerAttached = true;
- if (clearFirst) {
- const isMac = (typeof navigator !== 'undefined' && navigator.userAgent?.includes('Macintosh')) ||
- (typeof navigator !== 'undefined' && navigator.platform?.includes('Mac'));
- const selectAllModifier = isMac ? 4 : 2;
-
- // Select all
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
- type: 'keyDown', modifiers: selectAllModifier, key: 'a', code: 'KeyA'
- });
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
- type: 'keyUp', modifiers: selectAllModifier, key: 'a', code: 'KeyA'
- });
- await new Promise(r => setTimeout(r, 200));
-
- // Delete selected
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
- type: 'keyDown', key: 'Backspace', code: 'Backspace'
- });
- await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
- type: 'keyUp', key: 'Backspace', code: 'Backspace'
- });
- await new Promise(r => setTimeout(r, 200));
- }
-
- await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text });
+ const inserted = await dispatchCdpTextInsertion(tabId, text, position, selector);
+ if (!inserted.success) return inserted;
+ textInserted = true;
await chrome.debugger.detach({ tabId });
debuggerAttached = false;
@@ -20078,7 +20199,10 @@ async function executeCDPToolDirectUnlocked(request, tabId) {
return { success: true, method: 'cdp_direct', text, length: text.length };
} catch (error) {
automationLogger.logActionExecution(null, 'cdpInsertText', 'complete', { success: false, tabId, error: error.message });
- return cdpFailureResult(error);
+ const failure = cdpFailureResult(error);
+ return textInserted || error?.mayHaveExecuted
+ ? { ...failure, outcome: 'unknown', mayHaveExecuted: true, retryable: false }
+ : failure;
} finally {
if (debuggerAttached) {
try { await chrome.debugger.detach({ tabId }); } catch (_e) { /* ignore */ }
@@ -20149,7 +20273,7 @@ async function executeCDPToolDirectUnlocked(request, tabId) {
*/
async function handleMonacoEditorInsert(request, sender, sendResponse) {
const tabId = sender.tab?.id;
- const { text } = request;
+ const { text, clearFirst = true } = request;
if (!tabId || !text) {
sendResponse({ success: false, error: !tabId ? 'No tab ID' : 'No text provided' });
@@ -20160,8 +20284,8 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) {
const results = await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
- args: [text],
- func: (codeText) => {
+ args: [text, clearFirst],
+ func: (codeText, replaceAll) => {
// Attempt 1: Monaco editor API
if (typeof monaco !== 'undefined' && monaco.editor) {
const editors = typeof monaco.editor.getEditors === 'function'
@@ -20172,8 +20296,14 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) {
const model = editor.getModel();
if (model) {
const fullRange = model.getFullModelRange();
+ const editRange = replaceAll ? fullRange : {
+ startLineNumber: fullRange.endLineNumber,
+ startColumn: fullRange.endColumn,
+ endLineNumber: fullRange.endLineNumber,
+ endColumn: fullRange.endColumn
+ };
editor.executeEdits('fsb-automation', [{
- range: fullRange,
+ range: editRange,
text: codeText
}]);
// Move cursor to end
@@ -20189,8 +20319,14 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) {
if (models.length > 0) {
const model = models[0];
const fullRange = model.getFullModelRange();
+ const editRange = replaceAll ? fullRange : {
+ startLineNumber: fullRange.endLineNumber,
+ startColumn: fullRange.endColumn,
+ endLineNumber: fullRange.endLineNumber,
+ endColumn: fullRange.endColumn
+ };
model.pushEditOperations([], [{
- range: fullRange,
+ range: editRange,
text: codeText
}], () => null);
return { success: true, method: 'monaco_pushEditOperations' };
@@ -20202,7 +20338,7 @@ async function handleMonacoEditorInsert(request, sender, sendResponse) {
if (cmElement?.cmView?.view) {
const view = cmElement.cmView.view;
view.dispatch({
- changes: { from: 0, to: view.state.doc.length, insert: codeText }
+ changes: { from: replaceAll ? 0 : view.state.doc.length, to: view.state.doc.length, insert: codeText }
});
return { success: true, method: 'codemirror6_dispatch' };
}
@@ -20447,7 +20583,10 @@ async function handleKeyboardDebuggerAction(request, sender, sendResponse) {
tabId = sender.tab.id;
if (globalThis.FsbCdpLease && typeof globalThis.FsbCdpLease.acquire === 'function') {
- cdpLease = await globalThis.FsbCdpLease.acquire(tabId, { timeoutMs: 10000 });
+ cdpLease = await globalThis.FsbCdpLease.acquire(tabId, {
+ timeoutMs: 10000,
+ holdMs: keyboardActionLeaseHoldMs(method, text, keys, delay)
+ });
}
automationLogger.logActionExecution(null, `keyboard_${method}`, 'start', { tabId, key, specialKey });
@@ -20523,6 +20662,17 @@ async function handleKeyboardDebuggerAction(request, sender, sendResponse) {
}
}
+// Typing holds the debugger for every key. Budget each one generously (the
+// inter-key delay, the key down/up gap, and two CDP round trips) so a long
+// string is never handed to the next CDP caller part-way through.
+function keyboardActionLeaseHoldMs(method, text, keys, delay) {
+ let presses = 1;
+ if (method === 'typeText' && typeof text === 'string') presses = text.length;
+ if (method === 'pressKeySequence' && Array.isArray(keys)) presses = keys.length;
+ const perPressMs = (Number.isFinite(delay) ? Math.max(0, delay) : 50) + 100;
+ return 20000 + presses * perPressMs;
+}
+
/**
* Clean up keyboard emulator resources when tab is closed
*/
diff --git a/extension/content/actions.js b/extension/content/actions.js
index 391a97ddb..f0d56d068 100644
--- a/extension/content/actions.js
+++ b/extension/content/actions.js
@@ -10,6 +10,34 @@
const FSB = window.FSB;
const logger = FSB.logger;
+ function normalizeEditorText(value) {
+ return String(value ?? '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ');
+ }
+
+ function resolveTextEntryTarget(element) {
+ if (!element) return null;
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') return element;
+ if (element.isContentEditable) {
+ return element.closest('[contenteditable="true"], [contenteditable=""]') || element;
+ }
+ const descendants = element.querySelectorAll('input:not([type="hidden"]), textarea, [contenteditable="true"], [contenteditable=""]');
+ return descendants.length === 1 ? descendants[0] : null;
+ }
+
+ function readEditorText(element) {
+ return normalizeEditorText(element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'
+ ? element.value : element.innerText);
+ }
+
+ function selectEditableInsertion(element, replace) {
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ if (!replace) range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
// =============================================================================
// COORDINATE FALLBACK UTILITIES
// Used when all selectors fail and stored coordinates are available
@@ -149,29 +177,12 @@ async function clickAtCoordinates(params) {
element.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
element.dispatchEvent(new MouseEvent('click', mouseEventInit));
- // Also call native click as fallback
- if (typeof element.click === 'function') {
- element.click();
- }
-
// Wait for potential effects
await waitForStability('click');
- // Check if DOM click had effect; if not, try CDP mouse as final fallback
- let clickMethod = 'dom_coordinate';
- try {
- const cdpResult = await chrome.runtime.sendMessage({
- action: 'cdpMouseClick',
- x: viewportCenterX,
- y: viewportCenterY
- });
- if (cdpResult?.success) {
- clickMethod = 'cdp_coordinate';
- await waitForStability('click');
- }
- } catch (e) {
- // CDP unavailable, DOM click already dispatched
- }
+ // The event may have triggered a network action without a visible DOM change.
+ // Never dispatch a second click on an uncertain outcome.
+ const clickMethod = 'dom_coordinate';
logger.log('info', 'Coordinate fallback click executed', {
sessionId: FSB.sessionId,
@@ -186,7 +197,9 @@ async function clickAtCoordinates(params) {
});
return {
- success: true,
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
fallbackUsed: true,
clickedElement: {
tag: element.tagName,
@@ -196,7 +209,7 @@ async function clickAtCoordinates(params) {
coordinates: { x: viewportCenterX, y: viewportCenterY },
scrolled: scrollResult.scrolled,
method: clickMethod,
- message: `Clicked using ${clickMethod} fallback (selector-based approach failed)`
+ message: 'Click dispatched at coordinates; inspect the page before retrying because its effect is unknown'
};
}
@@ -2333,6 +2346,10 @@ const tools = {
const isObscured = readiness.failureReason && readiness.failureReason.includes('obscured');
if (isObscured && element && typeof element.click === 'function') {
try {
+ if (selectorUsed && !selectorUsed.startsWith('[text-match:') &&
+ FSB.querySelectorWithShadow(selectorUsed) !== element) {
+ return buildFailureReport('click', selectorUsed, null, 'Selector changed before click');
+ }
element.click();
await delay(300);
return {
@@ -2382,6 +2399,13 @@ const tools = {
if (!document.contains(element)) {
return buildFailureReport('click', params.selector, null, 'Element no longer in DOM');
}
+ if (selectorUsed && !selectorUsed.startsWith('[text-match:') &&
+ FSB.querySelectorWithShadow(selectorUsed) !== element) {
+ return buildFailureReport('click', selectorUsed, null, 'Selector changed before click');
+ }
+ if (params.text && !(element.innerText || element.textContent || '').toLowerCase().includes(params.text.toLowerCase())) {
+ return buildFailureReport('click', params.text, null, 'Text target changed before click');
+ }
// FIX: Handle target="_blank" links that would open in a new tab
// Instead, navigate in the current tab for automation continuity
@@ -2437,9 +2461,6 @@ const tools = {
element.dispatchEvent(new MouseEvent('mouseup', mouseEventInit));
element.dispatchEvent(new MouseEvent('click', mouseEventInit));
- // Also call native click as fallback for some elements
- element.click();
-
// VERIFY-04: Wait for page stability (REPLACE fixed 300ms with dynamic stability detection)
await waitForPageStability({ maxWait: 1000, stableTime: 200 });
@@ -2483,148 +2504,28 @@ const tools = {
// CRITICAL FIX: Return success=false when click has no effect
// This prevents AI from continuing after failed clicks
if (!hadEffect) {
- // FALLBACK: For anchor tags with valid href, try direct navigation
- // Google and other sites may intercept click events, preventing programmatic navigation
- const failedAnchor = element.tagName === 'A' ? element : element.closest('a');
- if (failedAnchor && failedAnchor.href &&
- failedAnchor.href.startsWith('http') &&
- !failedAnchor.href.includes('javascript:')) {
- logger.logActionExecution(FSB.sessionId, 'click', 'href_fallback', {
- href: failedAnchor.href,
- originalSelector: params.selector
- });
- window.location.href = failedAnchor.href;
- return {
- success: true,
- clicked: params.selector,
- hadEffect: true,
- navigationTriggered: true,
- method: 'href-fallback',
- message: 'Click had no effect, navigated via href fallback',
- targetUrl: failedAnchor.href,
- elementInfo: {
- tag: element.tagName,
- text: element.textContent?.trim().substring(0, 50),
- wasScrolledIntoView: wasScrolled
- }
- };
- }
-
- // FALLBACK 2: For form submit buttons, try form.submit()
- const isSubmitButton = (element.tagName === 'INPUT' && element.type === 'submit') ||
- (element.tagName === 'BUTTON' && (element.type === 'submit' || !element.type));
- const parentForm = element.closest('form');
- if (isSubmitButton && parentForm) {
- logger.logActionExecution(FSB.sessionId, 'click', 'form_submit_fallback', {
- formAction: parentForm.action,
- originalSelector: params.selector
- });
- try {
- parentForm.submit();
- return {
- success: true,
- clicked: params.selector,
- hadEffect: true,
- navigationTriggered: true,
- method: 'form-submit-fallback',
- message: 'Click had no effect, submitted form directly',
- elementInfo: {
- tag: element.tagName,
- text: element.textContent?.trim().substring(0, 50) || element.value?.substring(0, 50),
- wasScrolledIntoView: wasScrolled
- }
- };
- } catch (formError) {
- logger.warn('Form submit fallback failed', { error: formError.message });
- }
- }
-
- // FALLBACK 3: CDP mouse click at element coordinates (browser-level input)
- // Bypasses React synthetic events, Shadow DOM, and event listener interception
- try {
- const cdpRect = element.getBoundingClientRect();
- const cdpX = cdpRect.left + cdpRect.width / 2;
- const cdpY = cdpRect.top + cdpRect.height / 2;
-
- logger.logActionExecution(FSB.sessionId, 'click', 'cdp_mouse_fallback', {
- x: Math.round(cdpX), y: Math.round(cdpY), selector: params.selector
- });
-
- const cdpResult = await chrome.runtime.sendMessage({
- action: 'cdpMouseClick',
- x: cdpX,
- y: cdpY
- });
-
- if (cdpResult?.success) {
- await waitForPageStability({ maxWait: 1500, stableTime: 200 });
- const postState2 = captureActionState(element, 'click');
- const verification2 = verifyActionEffect(preState, postState2, 'click');
- const cdpHadEffect = verification2.verified ||
- verification2.changes?.urlChanged ||
- verification2.changes?.contentChanged ||
- verification2.changes?.elementCountChanged;
-
- if (cdpHadEffect) {
- actionRecorder.record(null, 'click', params, {
- selectorTried, selectorUsed: selectorTried, elementFound: true,
- coordinatesUsed: { x: Math.round(cdpX), y: Math.round(cdpY) },
- coordinateSource: 'cdp_mouse', success: true, hadEffect: true,
- duration: Date.now() - startTime
- });
- return {
- success: true,
- clicked: params.selector,
- hadEffect: true,
- method: 'cdp-mouse-fallback',
- message: 'DOM click had no effect, CDP mouse click succeeded',
- elementInfo: {
- tag: element.tagName,
- text: element.textContent?.trim().substring(0, 50),
- wasScrolledIntoView: wasScrolled
- }
- };
- }
- }
- } catch (cdpErr) {
- logger.debug('CDP mouse fallback unavailable', { error: cdpErr.message, sessionId: FSB.sessionId });
- }
-
- // Record action - click had no effect (all fallbacks exhausted)
const clickNoEffectDiagnostic = diagnoseElementFailure(selectorTried, element);
actionRecorder.record(null, 'click', params, {
selectorTried,
- selectorUsed: selectorTried,
+ selectorUsed,
elementFound: true,
elementDetails: captureElementDetails(element),
- coordinatesUsed: { x: Math.round(centerX), y: Math.round(centerY) },
- coordinateSource: 'selector',
success: false,
- error: 'Click executed but had no detectable effect on the page',
+ outcome: 'unknown',
+ mayHaveExecuted: true,
hadEffect: false,
- effectDetails: verification.changes,
- verification: {
- verified: verification.verified,
- changes: verification.changes,
- reason: verification.reason
- },
diagnostic: clickNoEffectDiagnostic,
duration: Date.now() - startTime
});
- const clickNoEffectReport = buildFailureReport('click', selectorTried, element, 'Click executed but had no detectable effect on the page', clickNoEffectDiagnostic);
- clickNoEffectReport.clicked = params.selector;
- clickNoEffectReport.hadEffect = false;
- clickNoEffectReport.verification = {
- preState,
- postState,
- verified: verification.verified,
- changes: verification.changes,
- reason: verification.reason,
- localChanges: verification.localChanges,
- confidence: verification.confidence,
- whatChanged: verification.whatChanged
+ return {
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
+ clicked: params.selector,
+ hadEffect: false,
+ error: 'Click was dispatched but its effect could not be confirmed. Inspect the page before retrying.',
+ verification
};
- return clickNoEffectReport;
}
// Record successful action
@@ -2832,6 +2733,7 @@ const tools = {
// Type text into an input
type: async (params) => {
const startTime = Date.now();
+ const clearFirst = params.clear_first !== false && params.clearFirst !== false;
logger.logActionExecution(FSB.sessionId, 'type', 'start', params);
// Build selectors array for alternative selector support
@@ -2844,6 +2746,7 @@ const tools = {
// Try each selector until one succeeds with verified effect
for (let selectorIndex = 0; selectorIndex < selectors.length; selectorIndex++) {
const currentSelector = selectors[selectorIndex];
+ let mutationAttempted = false;
logger.debug('Trying selector for type', { sessionId: FSB.sessionId, selectorIndex, selector: currentSelector });
try {
@@ -2870,22 +2773,27 @@ const tools = {
}
}
+ if (!FSB.isCanvasBasedEditor()) {
+ const editingTarget = resolveTextEntryTarget(element);
+ if (!editingTarget) {
+ lastAttemptError = 'Selector does not identify a single editable field';
+ continue;
+ }
+ element = editingTarget;
+ }
+
logger.logActionExecution(FSB.sessionId, 'type', 'element_ready', { tagName: element.tagName, scrolled: readiness.scrolled });
// Capture pre-state for verification
const preState = captureActionState(element, 'type');
+ mutationAttempted = true;
if (element) {
// Check if it's a valid input element with enhanced contenteditable detection
const isInput = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
// Enhanced universal text input detection for all platforms
- const isContentEditable = element.contentEditable === 'true' ||
- element.getAttribute('contenteditable') === 'true' ||
- element.hasAttribute('contenteditable') ||
- element.getAttribute('role') === 'textbox' ||
- // Universal messaging patterns
- FSB.isUniversalMessageInput(element);
+ const isContentEditable = element.isContentEditable === true;
const codeEditorInfo = FSB.detectCodeEditor(element);
const isCodeEditorInput = isInput && codeEditorInfo.isCodeEditor;
@@ -2928,8 +2836,11 @@ const tools = {
note: 'Google Sheets Name Box guard -- data redirected to active cell via keyboard emulator'
};
}
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' };
} catch (e) {
- logger.debug('Name Box guard typeWithKeys failed, falling through', { error: e.message });
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' };
}
}
}
@@ -2961,9 +2872,11 @@ const tools = {
};
}
} catch (twkError) {
- logger.debug('Google Sheets typeWithKeys failed, falling through to CDP', { error: twkError.message });
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' };
}
- // Fall through to standard CDP path as last resort
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' };
}
logger.logActionExecution(FSB.sessionId, 'type', 'canvas_editor_cdp_direct', { hostname: window.location.hostname });
@@ -2991,7 +2904,7 @@ const tools = {
}
// If clearFirst, select all and delete before pasting
- if (params.clearFirst) {
+ if (clearFirst) {
const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac');
await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
@@ -3042,14 +2955,20 @@ const tools = {
note: 'Google Docs -- markdown converted to HTML, pasted via clipboard for rich formatting'
};
}
- // If clipboard paste failed (verified -- no text appeared), fall through to plain CDP insertText
- logger.warn('Formatted paste failed (verified), falling back to plain CDP insertText', {
+ logger.warn('Formatted paste was not confirmed', {
error: pasteResult.error,
textLenBefore: pasteResult.textLenBefore,
- textLenAfter: pasteResult.textLenAfter
+ textLenAfter: pasteResult.textLenAfter,
+ nothingInserted: !!pasteResult.nothingInserted
});
+ if (!pasteResult.nothingInserted) {
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Formatted paste may have executed. Inspect the document before retrying.' };
+ }
+ // The paste provably changed nothing; the plain insertion below is the only write.
} catch (fmtError) {
- logger.debug('Formatted paste error, falling back to plain CDP insertText', { error: fmtError.message });
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Formatted paste may have executed. Inspect the document before retrying.' };
}
}
// --- END FORMATTED PASTE PATH ---
@@ -3061,7 +2980,7 @@ const tools = {
chrome.runtime.sendMessage({
action: 'cdpInsertText',
text: cdpText,
- clearFirst: !!params.clearFirst
+ clearFirst
}, (response) => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else if (response && response.success) resolve(response);
@@ -3083,14 +3002,8 @@ const tools = {
note: 'Canvas-based editor -- CDP insertion used, DOM validation skipped'
};
} catch (cdpError) {
- logger.debug('Canvas editor CDP failed, trying typeWithKeys', { error: cdpError.message });
- try {
- const twkResult = await tools.typeWithKeys({ text: params.text, clearFirst: false });
- if (twkResult.success) return { ...twkResult, note: 'canvas_editor_typeWithKeys_fallback' };
- } catch (twkError) {
- logger.debug('Canvas editor typeWithKeys also failed', { error: twkError.message });
- }
- return { success: false, error: 'Canvas-based editor: CDP and typeWithKeys both failed', typed: params.text };
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'CDP insertion may have executed. Inspect the editor before retrying.', typed: params.text };
}
}
@@ -3106,19 +3019,6 @@ const tools = {
if (!shouldSkipClick) {
element.click();
await waitForStability('light');
-
- if (document.activeElement !== element && element.id) {
- const label = document.querySelector(`label[for="${element.id}"]`);
- if (label) {
- label.click();
- await waitForStability('type_keystroke');
- }
- }
-
- if (document.activeElement !== element && element.parentElement) {
- element.parentElement.click();
- await waitForStability('type_keystroke');
- }
}
// Always focus after clicking
@@ -3126,12 +3026,10 @@ const tools = {
await waitForStability('type_keystroke');
// Final verification - ensure element is truly focused and ready
- let focusAttempts = 0;
- while (document.activeElement !== element && focusAttempts < 3) {
- element.click();
- element.focus();
- await waitForStability('light');
- focusAttempts++;
+ const focusAttempts = 0;
+ if (document.activeElement !== element) {
+ return { success: false, outcome: 'failed', mayHaveExecuted: false,
+ error: 'Editable field did not receive focus' };
}
// Universal text insertion handling for both input elements and contenteditable
@@ -3152,7 +3050,8 @@ const tools = {
const editorResult = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'monacoEditorInsert',
- text: params.text
+ text: params.text,
+ clearFirst
}, (response) => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else if (response?.success) resolve(response);
@@ -3183,10 +3082,16 @@ const tools = {
}
};
} catch (editorApiError) {
- logger.debug('Editor API failed, falling through to CDP', {
+ if (/No editor API found on page/.test(editorApiError.message)) {
+ logger.debug('No editor API available; trying CDP', { sessionId: FSB.sessionId });
+ } else {
+ logger.debug('Editor API result uncertain; skipping fallback', {
sessionId: FSB.sessionId,
error: editorApiError.message
});
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Editor insertion may have executed. Inspect the editor before retrying.' };
+ }
}
}
@@ -3203,7 +3108,7 @@ const tools = {
chrome.runtime.sendMessage({
action: 'cdpInsertText',
text: params.text,
- clearFirst: true
+ clearFirst
}, (response) => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else if (response?.success) resolve(response);
@@ -3233,10 +3138,12 @@ const tools = {
}
};
} catch (cdpCodeEditorError) {
- logger.debug('CDP code editor fast-path failed, falling through to standard methods', {
+ logger.debug('CDP code editor result uncertain; skipping fallback', {
sessionId: FSB.sessionId,
error: cdpCodeEditorError.message
});
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'CDP insertion may have executed. Inspect the editor before retrying.' };
}
}
@@ -3249,7 +3156,8 @@ const tools = {
if (!codeInserted && document.execCommand) {
try {
- element.select();
+ if (clearFirst) element.select();
+ else element.setSelectionRange(element.value.length, element.value.length);
if (document.execCommand('insertText', false, params.text)) {
codeInserted = true;
}
@@ -3259,44 +3167,11 @@ const tools = {
}
if (!codeInserted) {
- try {
- element.select();
- element.dispatchEvent(new InputEvent('beforeinput', {
- inputType: 'insertText',
- data: params.text,
- bubbles: true,
- cancelable: true,
- composed: true
- }));
- element.dispatchEvent(new InputEvent('input', {
- inputType: 'insertText',
- data: params.text,
- bubbles: true
- }));
- codeInserted = true;
- } catch (e) {
- logger.debug('Code editor InputEvent failed', { error: e.message });
- }
- }
-
- if (!codeInserted) {
- try {
- const dataTransfer = new DataTransfer();
- dataTransfer.setData('text/plain', params.text);
- element.dispatchEvent(new ClipboardEvent('paste', {
- clipboardData: dataTransfer,
- bubbles: true,
- cancelable: true
- }));
- await waitForStability('type_keystroke');
- codeInserted = true;
- } catch (e) {
- logger.debug('Code editor clipboard paste failed', { error: e.message });
+ if (element.value !== previousValue) {
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Editor content changed unexpectedly. Inspect it before retrying.' };
}
- }
-
- if (!codeInserted) {
- element.value = params.text;
+ element.value = clearFirst ? params.text : previousValue + params.text;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
@@ -3310,96 +3185,41 @@ const tools = {
} else if (isInput) {
previousValue = element.value;
- element.value = '';
- element.value = params.text;
+ element.value = clearFirst ? params.text : previousValue + params.text;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
} else if (isContentEditable) {
- previousValue = element.textContent || element.innerText || '';
- insertionSuccess = false;
-
- if (!insertionSuccess && document.execCommand) {
- try {
- element.focus();
- document.execCommand('selectAll', false, null);
- if (document.execCommand('insertText', false, params.text)) {
- insertionSuccess = true;
- }
- } catch (e) {
- logger.debug('execCommand insertText failed', { sessionId: FSB.sessionId, error: e.message });
- }
- }
-
- if (!insertionSuccess) {
- try {
- const dataTransfer = new DataTransfer();
- dataTransfer.setData('text/plain', params.text);
- const pasteEvent = new ClipboardEvent('paste', {
- clipboardData: dataTransfer,
- bubbles: true,
- cancelable: true
- });
- element.dispatchEvent(pasteEvent);
- await waitForStability('type_keystroke');
- if (element.textContent.includes(params.text)) {
- insertionSuccess = true;
- }
- } catch (e) {
- logger.debug('Clipboard paste simulation failed', { sessionId: FSB.sessionId, error: e.message });
- }
- }
-
- if (!insertionSuccess) {
- try {
- element.innerHTML = '';
- element.textContent = '';
- const textNode = document.createTextNode(params.text);
- element.appendChild(textNode);
- const range = document.createRange();
- const selection = window.getSelection();
- range.setStartAfter(textNode);
- range.collapse(true);
- selection.removeAllRanges();
- selection.addRange(range);
- insertionSuccess = true;
- } catch (e) {
- logger.debug('Range/Selection API insertion failed', { sessionId: FSB.sessionId, error: e.message });
- }
+ previousValue = readEditorText(element);
+ element.focus();
+ selectEditableInsertion(element, clearFirst);
+ let commandReportedSuccess = false;
+ try {
+ commandReportedSuccess = Boolean(document.execCommand &&
+ document.execCommand('insertText', false, params.text));
+ } catch (error) {
+ logger.debug('Editable insertText failed', { sessionId: FSB.sessionId, error: error.message });
}
-
+ await waitForStability('type_keystroke');
+ const expected = clearFirst
+ ? normalizeEditorText(params.text)
+ : previousValue + normalizeEditorText(params.text);
+ const observed = readEditorText(element);
+ insertionSuccess = observed === expected;
if (!insertionSuccess) {
- if (element.innerHTML.includes('
') || element.innerHTML.includes('
')) {
- element.innerHTML = '';
- } else {
- element.textContent = '';
- }
- try {
- element.textContent = params.text;
- insertionSuccess = true;
- } catch (e) {
- logger.debug('Direct manipulation failed', { sessionId: FSB.sessionId, error: e.message });
- }
+ const mayHaveExecuted = commandReportedSuccess || observed !== previousValue;
+ return {
+ success: false,
+ outcome: mayHaveExecuted ? 'unknown' : 'failed',
+ mayHaveExecuted,
+ error: mayHaveExecuted
+ ? 'Editable text changed but did not match the requested text. Inspect it before retrying.'
+ : 'The editable field did not accept the text.',
+ expectedValue: expected,
+ actualValue: observed,
+ final_text: observed
+ };
}
-
- const events = [
- new Event('input', { bubbles: true }),
- new Event('change', { bubbles: true }),
- new KeyboardEvent('keydown', { bubbles: true }),
- new KeyboardEvent('keyup', { bubbles: true }),
- new Event('blur', { bubbles: true }),
- new Event('focus', { bubbles: true })
- ];
-
- events.forEach(event => {
- try {
- element.dispatchEvent(event);
- } catch (e) {
- logger.debug('Event dispatch failed', { sessionId: FSB.sessionId, eventType: event.type, error: e.message });
- }
- });
-
- await waitForStability('type_keystroke');
}
// Gmail/email recipient field: dispatch Tab to confirm the recipient "chip"
@@ -3438,52 +3258,23 @@ const tools = {
element.dispatchEvent(enterUpEvent);
}
- // Post-typing validation
- const finalValue = isInput ? (element.value || '') : (element.textContent || element.value || '');
- const typingSuccessful = finalValue.includes(params.text) || finalValue === params.text;
-
- // Amazon-specific validation
+ // Compare the rendered editor value, preserving line breaks and detecting
+ // duplicate inserts before any further action can mutate the field.
+ const finalCheck = readEditorText(element);
+ const expectedValue = clearFirst
+ ? normalizeEditorText(params.text)
+ : normalizeEditorText(previousValue) + normalizeEditorText(params.text);
+ const finalSuccess = finalCheck === expectedValue;
const isAmazonSearch = element.id === 'twotabsearchtextbox' ||
- element.name === 'searchtext' ||
- window.location.hostname.includes('amazon');
-
- if (isAmazonSearch && !typingSuccessful) {
- logger.logActionExecution(FSB.sessionId, 'type', 'amazon_retry', { reason: 'initial_typing_failed' });
- try {
- element.focus();
- await waitForStability('light');
- element.value = '';
- element.value = params.text;
- element.dispatchEvent(new Event('input', { bubbles: true }));
- element.dispatchEvent(new Event('change', { bubbles: true }));
- element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: params.text.slice(-1) }));
- element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: params.text.slice(-1) }));
- await waitForStability('type_complete');
- const retryValue = element.value || '';
- if (retryValue.includes(params.text) || retryValue === params.text) {
- logger.logActionExecution(FSB.sessionId, 'type', 'amazon_retry_success', {});
- }
- } catch (amazonError) {
- logger.warn('Amazon-specific retry failed', { sessionId: FSB.sessionId, error: amazonError.message });
- }
- }
-
- // CRITICAL FIX: Strengthen validation
- const finalCheck = isInput ? (element.value || '') : (element.textContent || element.value || '');
- const trimmedFinal = finalCheck.trim();
- const trimmedExpected = params.text.trim();
- const exactMatch = trimmedFinal === trimmedExpected;
- const contentEditableMatch = isContentEditable &&
- trimmedFinal.replace(/\s+/g, ' ') === trimmedExpected.replace(/\s+/g, ' ');
- const finalSuccess = exactMatch || contentEditableMatch;
- const contentEditableActuallyWorked = !isContentEditable || (insertionSuccess && finalSuccess);
+ element.name === 'searchtext' ||
+ window.location.hostname.includes('amazon');
// Gmail recipient chip check
if (isRecipientField && looksLikeEmail && !finalSuccess) {
const chipEl = element.closest('[role="list"], .fX, .afV')?.querySelector(
'.vR, [data-hovercard-id], [data-name], .afX'
);
- const fieldCleared = trimmedFinal === '' || !trimmedFinal.includes(params.text);
+ const fieldCleared = finalCheck.trim() === '' || !finalCheck.includes(params.text);
if (chipEl || fieldCleared) {
logger.debug('Recipient chip detected or field cleared after Tab, treating as success', {
sessionId: FSB.sessionId, chipFound: !!chipEl, fieldCleared
@@ -3505,96 +3296,33 @@ const tools = {
}
}
- // Return failure if typing didn't work
- if (!finalSuccess || (isContentEditable && !isCodeEditorInput && !insertionSuccess)) {
- const recheck = isInput ? (element.value || '') : (element.textContent || element.innerText || '');
- if (recheck.includes(params.text)) {
- return {
- success: true,
- typed: params.text,
- method: 'standard',
- pressedEnter: !!params.pressEnter,
- clickedFirst: !shouldSkipClick,
- hadEffect: true,
- note: 'recheck_confirmed_text_present',
- elementInfo: {
- tag: element.tagName,
- type: isInput ? element.type : 'contenteditable',
- name: element.name || element.id || element.className
- }
- };
- }
-
- // ENHANCED: Try CDP-based text insertion as last resort
- logger.logActionExecution(FSB.sessionId, 'type', 'cdp_fallback_attempt', { reason: 'standard_methods_failed' });
-
- try {
- const cdpResult = await new Promise((resolve, reject) => {
- chrome.runtime.sendMessage({
- action: 'cdpInsertText',
- text: params.text,
- clearFirst: true
- }, (response) => {
- if (chrome.runtime.lastError) {
- reject(new Error(chrome.runtime.lastError.message));
- } else if (response && response.success) {
- resolve(response);
- } else {
- reject(new Error(response?.error || 'CDP insertion failed'));
- }
- });
- });
-
- await waitForStability('type_complete');
- const cdpCanvasEditor = FSB.isCanvasBasedEditor();
- const cdpFinalCheck = cdpCanvasEditor ? '' : (isInput ? (element.value || '') : (element.textContent || element.value || ''));
- const cdpSuccess = cdpCanvasEditor || cdpFinalCheck.includes(params.text) || cdpFinalCheck.trim() === params.text.trim();
-
- if (cdpSuccess) {
- logger.logActionExecution(FSB.sessionId, 'type', cdpCanvasEditor ? 'cdp_fallback_canvas_success' : 'cdp_fallback_success', {});
- return {
- success: true,
- typed: params.text,
- method: cdpCanvasEditor ? 'cdp_fallback_canvas' : 'cdp_fallback',
- pressedEnter: !!params.pressEnter,
- clickedFirst: !shouldSkipClick,
- hadEffect: true,
- note: cdpCanvasEditor ? 'Canvas-based editor -- DOM validation skipped, CDP trusted' : undefined,
- elementInfo: {
- tag: element.tagName,
- type: isInput ? element.type : 'contenteditable',
- name: element.name || element.id || element.className
- }
- };
- }
- } catch (cdpError) {
- logger.debug('CDP fallback failed', { sessionId: FSB.sessionId, error: cdpError.message });
- }
-
+ if (!finalSuccess || (isContentEditable && !insertionSuccess)) {
+ const mayHaveExecuted = finalCheck !== normalizeEditorText(previousValue);
return {
success: false,
- error: isContentEditable
- ? 'ContentEditable insertion failed - text not entered correctly (CDP fallback also failed)'
- : 'Text validation failed - expected text not found in element',
+ outcome: mayHaveExecuted ? 'unknown' : 'failed',
+ mayHaveExecuted,
+ error: mayHaveExecuted
+ ? 'Text changed but did not match the requested value. Inspect the field before retrying.'
+ : 'The field did not accept the requested text.',
typed: params.text,
actualValue: finalCheck,
- expectedValue: params.text,
- pressedEnter: !!params.pressEnter,
- clickedFirst: !shouldSkipClick,
- focused: document.activeElement === element,
- insertionSuccess: isContentEditable ? insertionSuccess : undefined,
- validationPassed: false,
- cdpAttempted: true,
- elementInfo: {
- tag: element.tagName,
- type: isInput ? element.type : 'contenteditable',
- previousValue: previousValue.substring(0, 20),
- name: element.name || element.id || FSB.getClassName(element),
- contentEditable: isContentEditable
- },
- suggestion: isContentEditable
- ? 'Try alternative selector or wait for page to be ready'
- : 'Element may not accept input correctly'
+ expectedValue,
+ final_text: finalCheck
+ };
+ }
+
+ if (isContentEditable) {
+ return {
+ success: true,
+ typed: params.text,
+ final_text: finalCheck,
+ actualValue: finalCheck,
+ selector: currentSelector,
+ hadEffect: true,
+ insertionSuccess: true,
+ validationPassed: true,
+ pressedEnter: !!params.pressEnter
};
}
@@ -3607,13 +3335,8 @@ const tools = {
lastVerification = verification;
if (!verification.verified) {
- logger.debug('Type verification failed, trying next selector', {
- sessionId: FSB.sessionId,
- selector: currentSelector,
- reason: verification.reason
- });
- lastAttemptError = `Type action had no verified effect: ${verification.reason}`;
- continue; // Try next selector
+ verification.verified = true;
+ verification.reason = 'Rendered editor value matches the requested text';
}
// Record successful action
@@ -3644,6 +3367,7 @@ const tools = {
changes: verification.changes
},
actualValue: finalCheck,
+ final_text: finalCheck,
pressedEnter: !!params.pressEnter,
clickedFirst: !shouldSkipClick,
focused: document.activeElement === element,
@@ -3702,6 +3426,11 @@ const tools = {
currentSelector: currentSelector
});
+ if (mutationAttempted) {
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Text insertion may have executed. Inspect the field before retrying.' };
+ }
+
if (lastVerification && lastVerification.verified) {
logger.warn('Type verification passed but post-success error occurred, returning success', {
sessionId: FSB.sessionId,
@@ -3754,10 +3483,13 @@ const tools = {
note: 'Google Sheets fallback -- keyboard emulator used (all selectors exhausted)'
};
}
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion was not confirmed. Inspect the cell before retrying.' };
} catch (twkErr) {
logger.debug('Google Sheets fallback typeWithKeys failed', { error: twkErr.message });
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Keyboard insertion may have executed. Inspect the cell before retrying.' };
}
- // Fall through to standard canvas CDP fallback as last resort
}
logger.logActionExecution(FSB.sessionId, 'type', 'canvas_fallback_attempt', {
@@ -3784,7 +3516,7 @@ const tools = {
chrome.runtime.sendMessage({
action: 'cdpInsertText',
text: params.text,
- clearFirst: !!params.clearFirst
+ clearFirst
}, (response) => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else if (response && response.success) resolve(response);
@@ -3805,13 +3537,8 @@ const tools = {
};
} catch (cdpFallbackErr) {
logger.debug('Canvas editor CDP fallback failed', { error: cdpFallbackErr.message });
- try {
- const twkResult = await tools.typeWithKeys({ text: params.text, clearFirst: false });
- if (twkResult.success) return { ...twkResult, note: 'canvas_editor_fallback_typeWithKeys' };
- } catch (twkErr) {
- logger.debug('Canvas editor fallback typeWithKeys also failed', { error: twkErr.message });
- }
- lastAttemptError = `Canvas editor CDP fallback failed: ${cdpFallbackErr.message}`;
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'CDP insertion may have executed. Inspect the editor before retrying.' };
}
}
@@ -3870,7 +3597,6 @@ const tools = {
}
const isInsideForm = !!element.closest('form');
- const formElement = element.closest('form');
const preState = captureActionState(element, 'pressEnter');
element.focus();
@@ -3894,51 +3620,16 @@ const tools = {
lastVerification = verification;
if (!verification.verified && isInsideForm) {
- // Phase 129: Enter had no effect -- try clicking the submit button as fallback
- const submitButton = findSubmitButton(formElement);
- if (submitButton) {
- const fallbackPreState = captureActionState(submitButton, 'click');
- submitButton.click();
- await waitForPageStability({ maxWait: 2000, stableTime: 300 });
- const fallbackPostState = captureActionState(submitButton, 'click');
- const fallbackVerification = verifyActionEffect(fallbackPreState, fallbackPostState, 'click');
-
- if (fallbackVerification.verified) {
- actionRecorder.record(null, 'pressEnter', params, {
- selectorTried: params.selector,
- selectorUsed: currentSelector,
- elementFound: true,
- elementDetails: captureElementDetails(submitButton),
- coordinatesUsed: null,
- coordinateSource: null,
- success: true,
- hadEffect: true,
- usedSubmitFallback: true,
- effectDetails: fallbackVerification.changes,
- duration: Date.now() - startTime
- });
-
- return {
- success: true,
- key: 'Enter',
- selector: currentSelector,
- selectorIndex: selectorIndex,
- usedFallback: selectorIndex > 0,
- usedSubmitFallback: true,
- submitButtonSelector: submitButton.id ? `#${submitButton.id}` : submitButton.className ? `.${submitButton.className.split(' ')[0]}` : submitButton.tagName.toLowerCase(),
- hadEffect: true,
- isInsideForm: true,
- verification: {
- verified: true,
- reason: 'Submit button click fallback triggered form submission',
- changes: fallbackVerification.changes
- }
- };
- }
- }
- // Submit button not found or click had no effect either -- continue to next selector
- lastAttemptError = `Enter key pressed but form submission had no effect${submitButton ? ' (submit button fallback also failed)' : ' (no submit button found in form)'}`;
- continue;
+ return {
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
+ key: 'Enter',
+ selector: currentSelector,
+ hadEffect: false,
+ error: 'Enter was dispatched but submission could not be confirmed. Inspect the page before retrying.',
+ verification
+ };
}
actionRecorder.record(null, 'pressEnter', params, {
@@ -4245,6 +3936,7 @@ const tools = {
const { element: searchInput, tier, selector: matchedSelector } = detected;
+ let submissionAttempted = false;
try {
// Ensure element is ready (also dismisses cookie consent per Phase 130)
if (typeof FSB.smartEnsureReady === 'function') {
@@ -4278,27 +3970,17 @@ const tools = {
key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
bubbles: true, cancelable: true
});
+ submissionAttempted = true;
searchInput.dispatchEvent(enterDown);
searchInput.dispatchEvent(enterUp);
// Wait for page stability after submit
await waitForPageStability({ maxWait: 3000, stableTime: 500 });
- // If URL did not change, try submit button fallback (reuse Phase 129 findSubmitButton)
if (window.location.href === preUrl) {
- const form = searchInput.closest('form');
- if (form) {
- const submitBtn = findSubmitButton(form) ||
- form.querySelector('[role="button"][aria-label*="search" i]');
- if (submitBtn) {
- submitBtn.click();
- await waitForPageStability({ maxWait: 3000, stableTime: 500 });
- } else {
- // Last resort: submit the form directly
- form.submit();
- await waitForPageStability({ maxWait: 3000, stableTime: 500 });
- }
- }
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Search submission may have executed. Inspect the page before retrying.',
+ query: params.query, searchInputSelector: matchedSelector, tier };
}
return {
@@ -4310,7 +3992,11 @@ const tools = {
url: window.location.href
};
} catch (error) {
- // On error, fall back to Google
+ if (submissionAttempted) {
+ return { success: false, outcome: 'unknown', mayHaveExecuted: true,
+ error: 'Search submission may have executed. Inspect the page before retrying.',
+ query: params.query, searchInputSelector: matchedSelector, tier };
+ }
const googleResult = tools.searchGoogle(params);
return { ...googleResult, method: 'google-fallback', siteSearchError: error.message };
}
@@ -4683,12 +4369,26 @@ const tools = {
if (response.success) {
return { success: true, key, method: 'debuggerAPI', target: selector || 'activeElement', result: response.result };
} else {
+ if (response.result?.keyDownDispatched || response.result?.mayHaveExecuted) {
+ return {
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
+ key,
+ error: 'The key may have reached the page. Inspect its effect before retrying.'
+ };
+ }
cdpError = (response && response.error) || 'debugger API returned failure';
logger.logRecovery(FSB.sessionId, 'debugger_api_failed', 'dom_events_fallback', 'started', { error: response.error });
}
} catch (error) {
- cdpError = error.message || 'debugger API unavailable';
- logger.logRecovery(FSB.sessionId, 'debugger_api_unavailable', 'dom_events_fallback', 'started', { error: error.message });
+ return {
+ success: false,
+ outcome: 'unknown',
+ mayHaveExecuted: true,
+ key,
+ error: 'The key dispatch lost its response. Inspect the page before retrying.'
+ };
}
}
diff --git a/extension/content/dom-state.js b/extension/content/dom-state.js
index de13027d5..8f37c6537 100644
--- a/extension/content/dom-state.js
+++ b/extension/content/dom-state.js
@@ -529,6 +529,18 @@
return null;
}
+ if (!selector.includes('>>>') && !selector.startsWith('/')) {
+ try {
+ if (typeof element.matches !== 'function' || !element.matches(selector)) {
+ this.cache.delete(selector);
+ return null;
+ }
+ } catch (_error) {
+ this.cache.delete(selector);
+ return null;
+ }
+ }
+
return element;
}
diff --git a/extension/content/messaging.js b/extension/content/messaging.js
index c8c98e520..80e4efafd 100644
--- a/extension/content/messaging.js
+++ b/extension/content/messaging.js
@@ -460,8 +460,11 @@
/**
* Write HTML to the system clipboard and simulate paste via CDP.
+ * nothingInserted is true only when the paste provably left the document
+ * unchanged, so the caller can fall back to a plain insert without duplicating text.
*/
async function clipboardPasteHTML(html, plainText) {
+ let pasteDispatched = false;
try {
const htmlBlob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([plainText], { type: 'text/plain' });
@@ -474,24 +477,28 @@
await navigator.clipboard.write([clipboardItem]);
} catch (clipErr) {
logger.warn('clipboardPasteHTML: clipboard.write() failed', { error: clipErr.message });
- return { success: false, method: 'clipboard_paste_html', error: 'Clipboard write failed: ' + clipErr.message };
+ return { success: false, method: 'clipboard_paste_html', error: 'Clipboard write failed: ' + clipErr.message,
+ nothingInserted: true };
}
logger.debug('clipboardPasteHTML: clipboard written', { htmlLength: html.length, plainTextLength: plainText.length });
await new Promise(r => setTimeout(r, 150));
- const getDocTextLength = () => {
+ // Canvas-rendered Docs have no paragraph elements, so an unchanged length there proves nothing.
+ const measureDocText = () => {
const pageElements = document.querySelectorAll('.kix-paragraphrenderer');
- let totalLen = 0;
+ let length = 0;
for (const el of pageElements) {
- totalLen += (el.textContent || '').length;
+ length += (el.textContent || '').length;
}
- return totalLen;
+ return { length, measurable: pageElements.length > 0 };
};
- const textLenBefore = getDocTextLength();
+ const before = measureDocText();
+ const textLenBefore = before.length;
const isMac = navigator.userAgent?.includes('Macintosh') || navigator.platform?.includes('Mac');
+ pasteDispatched = true;
const pasteResult = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'keyboardDebuggerAction',
@@ -514,13 +521,20 @@
await new Promise(r => setTimeout(r, 800));
- const textLenAfter = getDocTextLength();
+ // A large paste can land late; recheck before treating it as absent.
+ let after = measureDocText();
+ for (let recheck = 0; recheck < 3 && after.length <= textLenBefore; recheck++) {
+ await new Promise(r => setTimeout(r, 400));
+ after = measureDocText();
+ }
+ const textLenAfter = after.length;
const textInserted = textLenAfter > textLenBefore;
logger.debug('clipboardPasteHTML: verification', {
textLenBefore,
textLenAfter,
textInserted,
+ measurable: before.measurable && after.measurable,
expectedMinChars: Math.min(plainText.length, 10)
});
@@ -531,14 +545,15 @@
method: 'clipboard_paste_html',
error: 'Paste dispatched but no text appeared in editor (cursor may not be in editable area)',
textLenBefore,
- textLenAfter
+ textLenAfter,
+ nothingInserted: before.measurable && after.measurable && textLenAfter === textLenBefore
};
}
return { success: true, method: 'clipboard_paste_html', textLenBefore, textLenAfter };
} catch (error) {
logger.warn('clipboardPasteHTML failed', { error: error.message });
- return { success: false, method: 'clipboard_paste_html', error: error.message };
+ return { success: false, method: 'clipboard_paste_html', error: error.message, nothingInserted: !pasteDispatched };
}
}
diff --git a/extension/content/selectors.js b/extension/content/selectors.js
index f6bcd2fcb..324372563 100644
--- a/extension/content/selectors.js
+++ b/extension/content/selectors.js
@@ -448,44 +448,7 @@
* @returns {string} A valid CSS selector
*/
function sanitizeSelector(selector) {
- if (!selector || typeof selector !== 'string') {
- return selector;
- }
-
- // Remove jQuery-style pseudo-selectors that aren't valid CSS
- const invalidPseudos = [
- /:contains\([^)]*\)/gi, // :contains('text') - jQuery only
- /:has\([^)]*\)/gi, // :has() - limited browser support, can cause issues
- /:eq\(\d+\)/gi, // :eq(n) - jQuery only
- /:gt\(\d+\)/gi, // :gt(n) - jQuery only
- /:lt\(\d+\)/gi, // :lt(n) - jQuery only
- /:first(?![-\w])/gi, // :first - jQuery only (but not :first-child, :first-of-type)
- /:last(?![-\w])/gi, // :last - jQuery only (but not :last-child, :last-of-type)
- /:even/gi, // :even - jQuery only
- /:odd/gi, // :odd - jQuery only
- /:visible/gi, // :visible - jQuery only
- /:hidden/gi, // :hidden - jQuery only
- /:animated/gi, // :animated - jQuery only
- /:parent/gi // :parent - jQuery only
- ];
-
- let sanitized = selector;
- for (const pattern of invalidPseudos) {
- sanitized = sanitized.replace(pattern, '');
- }
-
- // Clean up any resulting empty selectors or dangling commas
- // Split by comma, filter out empty parts, rejoin
- const parts = sanitized.split(',')
- .map(s => s.trim())
- .filter(s => s.length > 0 && s !== '*'); // Remove empty or just asterisk
-
- if (parts.length === 0) {
- logger.warn('Selector completely invalidated after sanitization', { sessionId: FSB.sessionId, selector });
- return null;
- }
-
- return parts.join(', ');
+ return typeof selector === 'string' ? selector.trim() || null : null;
}
// ============================================================================
@@ -495,7 +458,7 @@
// Query selector that supports shadow DOM
// SPEED-04: Uses elementCache for faster repeated lookups
function querySelectorWithShadow(selector) {
- // Sanitize selector first to remove invalid pseudo-selectors
+ // Never strip selector conditions: unsupported syntax must fail closed.
const sanitized = sanitizeSelector(selector);
if (!sanitized) {
logger.warn('Cannot query with invalid selector', { sessionId: FSB.sessionId, selector });
diff --git a/extension/site-guides/index.js b/extension/site-guides/index.js
index 51b517acd..6c921aac4 100644
--- a/extension/site-guides/index.js
+++ b/extension/site-guides/index.js
@@ -238,6 +238,8 @@ function getGuideForTask(task, url) {
}
}
}
+ if (bestMatch?.site === 'Twitter/X' && url &&
+ !bestMatch.patterns.some(pattern => pattern.test(url))) return null;
if (bestMatch) return bestMatch;
return null;
diff --git a/extension/site-guides/social/twitter.js b/extension/site-guides/social/twitter.js
index fa45924ec..5a4ac7060 100644
--- a/extension/site-guides/social/twitter.js
+++ b/extension/site-guides/social/twitter.js
@@ -15,7 +15,7 @@ registerSiteGuide({
site: 'Twitter/X',
category: 'Social Media',
patterns: [
- /(twitter\.com|x\.com)/i
+ /^https?:\/\/(?:www\.|mobile\.)?(?:twitter\.com|x\.com)(?::\d+)?(?:[/?#]|$)/i
],
guidance: `AUTOPILOT STRATEGY HINTS (from v0.9.7 diagnostic SCROLL-01):
- [scroll] Track tweets by permalink href Set -- virtualized DOM recycles ~20-40 elements
@@ -45,7 +45,10 @@ SEARCH:
COMPOSING POSTS:
- Post text area: [data-testid="tweetTextarea_0"]
- Post/Tweet button (inline): [data-testid="tweetButtonInline"]
+- Post/Tweet button (modal composer): [data-testid="tweetButton"]
- New post button (sidebar): [data-testid="SideNav_NewTweet_Button"] or [aria-label="Post"]
+- Before posting, inspect the account switcher and confirm the intended account is active.
+- Before replying, open the intended post and verify its author and permalink in the reply context.
- Schedule post: [data-testid="scheduleOption"] or [aria-label="Schedule post"]
- Grok AI enhancement: [data-testid="grokImgGen"] or [aria-label="Enhance your post with Grok"]
- Add GIF: [data-testid="gifSearchButton"]
@@ -137,8 +140,9 @@ SCROLL TIMING:
searchBox: 'input[data-testid="SearchBox_Search_Input"]',
tweetCompose: '[data-testid="tweetTextarea_0"]',
tweetButton: '[data-testid="tweetButtonInline"]',
+ modalTweetButton: '[data-testid="tweetButton"]',
replyInput: '[data-testid="tweetTextarea_0"]',
- replyButton: '[data-testid="tweetButton"]',
+ replyButton: '[data-testid="tweetButtonInline"]',
likeButton: '[data-testid="like"]',
retweetButton: '[data-testid="retweet"]',
dmButton: '[data-testid="sendDMFromProfile"]',
@@ -173,12 +177,20 @@ SCROLL TIMING:
},
workflows: {
createPost: [
+ 'Check [data-testid="SideNav_AccountSwitcher_Button"] and confirm the intended account',
'Click the compose area or new post button',
'Wait for editor to load',
'Type the post content in [data-testid="tweetTextarea_0"]',
- 'Click the Post button [data-testid="tweetButtonInline"]',
+ 'Use [data-testid="tweetButtonInline"] for inline compose or [data-testid="tweetButton"] for the modal composer',
'Verify post was created'
],
+ replyToPost: [
+ 'Open the intended post and verify its author and /status/ permalink',
+ 'Confirm the active account in [data-testid="SideNav_AccountSwitcher_Button"]',
+ 'Type in the reply editor [data-testid="tweetTextarea_0"]',
+ 'Click the Post button inside the same reply composer only once',
+ 'Verify the reply appears under the intended post before trying again'
+ ],
sendMessage: [
'Click Messages tab [data-testid="AppTabBar_DirectMessage_Link"]',
'Open new message or find existing conversation',
@@ -209,6 +221,8 @@ SCROLL TIMING:
]
},
warnings: [
+ 'Check the active account before posting, replying, liking, or sending a message',
+ 'Check the author and permalink of the parent post before replying',
'Twitter/X rate-limits actions -- avoid rapid clicking',
'X/Twitter now has Grok AI integration for post enhancement via [data-testid="grokImgGen"]',
'X/Twitter navigation uses AppTabBar data-testid pattern -- more reliable than aria-label which can include notification counts',
diff --git a/extension/ui/onboarding.css b/extension/ui/onboarding.css
index c0256e8eb..18b020f49 100644
--- a/extension/ui/onboarding.css
+++ b/extension/ui/onboarding.css
@@ -271,9 +271,9 @@ a:hover {
}
.ob-node.active {
- background: rgba(255, 107, 53, 0.12);
+ background: var(--primary);
border-color: var(--primary);
- color: var(--primary);
+ color: #111827;
box-shadow: 0 0 0 4px rgba(255, 107, 53, 0.1);
}
diff --git a/extension/utils/cdp-lease.js b/extension/utils/cdp-lease.js
index 01f9e914d..4a0194a40 100644
--- a/extension/utils/cdp-lease.js
+++ b/extension/utils/cdp-lease.js
@@ -9,6 +9,7 @@
'use strict';
const queues = new Map();
+ const DEFAULT_HOLD_MS = 20000;
function busyError(tabId) {
const error = new Error(`The debugger for tab ${tabId} is busy. Retry the operation.`);
@@ -18,19 +19,28 @@
return error;
}
- function makeLease(tabId, state) {
+ function holdMsFrom(options) {
+ return Number.isFinite(options.holdMs) && options.holdMs > 0
+ ? options.holdMs
+ : DEFAULT_HOLD_MS;
+ }
+
+ // The watchdog frees the tab from a holder that hung. Holders whose work
+ // legitimately runs longer pass holdMs so they are not preempted mid-use.
+ function makeLease(tabId, state, holdMs) {
let released = false;
- return {
+ const lease = {
tabId,
release() {
if (released) return;
released = true;
+ clearTimeout(watchdog);
while (state.waiters.length > 0) {
const waiter = state.waiters.shift();
if (waiter.cancelled) continue;
clearTimeout(waiter.timer);
- waiter.resolve(makeLease(tabId, state));
+ waiter.resolve(makeLease(tabId, state, waiter.holdMs));
return;
}
@@ -38,6 +48,9 @@
queues.delete(tabId);
}
};
+ const watchdog = setTimeout(() => lease.release(), holdMs);
+ if (typeof watchdog.unref === 'function') watchdog.unref();
+ return lease;
}
function acquire(tabId, options = {}) {
@@ -48,6 +61,7 @@
const timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(0, options.timeoutMs)
: 10000;
+ const holdMs = holdMsFrom(options);
let state = queues.get(tabId);
if (!state) {
state = { active: false, waiters: [] };
@@ -56,11 +70,11 @@
if (!state.active) {
state.active = true;
- return Promise.resolve(makeLease(tabId, state));
+ return Promise.resolve(makeLease(tabId, state, holdMs));
}
return new Promise((resolve, reject) => {
- const waiter = { resolve, reject, cancelled: false, timer: null };
+ const waiter = { resolve, reject, holdMs, cancelled: false, timer: null };
waiter.timer = setTimeout(() => {
waiter.cancelled = true;
reject(busyError(tabId));
diff --git a/extension/utils/keyboard-emulator.js b/extension/utils/keyboard-emulator.js
index 0074ebff0..512b1d161 100644
--- a/extension/utils/keyboard-emulator.js
+++ b/extension/utils/keyboard-emulator.js
@@ -343,6 +343,7 @@ class KeyboardEmulator {
* @returns {Promise